diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml new file mode 100644 index 000000000000..91dce3515585 --- /dev/null +++ b/.config/cypress-devcontainer.yml @@ -0,0 +1,211 @@ +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Misskey configuration +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# ┌─────┐ +#───┘ URL └───────────────────────────────────────────────────── + +# Final accessible URL seen by a user. +url: 'http://misskey.local' + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# URL SETTINGS AFTER THAT! + +# ┌───────────────────────┐ +#───┘ Port and TLS settings └─────────────────────────────────── + +# +# Misskey requires a reverse proxy to support HTTPS connections. +# +# +----- https://example.tld/ ------------+ +# +------+ |+-------------+ +----------------+| +# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# +------+ |+-------------+ +----------------+| +# +---------------------------------------+ +# +# You need to set up a reverse proxy. (e.g. nginx) +# An encrypted connection with HTTPS is highly recommended +# because tokens may be transferred in GET requests. + +# The port that your Misskey server should listen on. +port: 61812 + +# ┌──────────────────────────┐ +#───┘ PostgreSQL configuration └──────────────────────────────── + +db: + host: db + port: 5432 + + # Database name + db: misskey + + # Auth + user: postgres + pass: postgres + + # Whether disable Caching queries + #disableCache: true + + # Extra Connection options + #extra: + # ssl: true + +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + +# ┌─────────────────────┐ +#───┘ Redis configuration └───────────────────────────────────── + +redis: + host: redis + port: 6379 + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 + #pass: example-pass + #prefix: example-prefix + #db: 1 + +#redisForPubsub: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForTimelines: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +# ┌───────────────────────────┐ +#───┘ MeiliSearch configuration └───────────────────────────── + +#meilisearch: +# host: meilisearch +# port: 7700 +# apiKey: '' +# ssl: true +# index: '' + +# ┌───────────────┐ +#───┘ ID generation └─────────────────────────────────────────── + +# You can select the ID generation method. +# You don't usually need to change this setting, but you can +# change it according to your preferences. + +# Available methods: +# aid ... Short, Millisecond accuracy +# aidx ... Millisecond accuracy +# meid ... Similar to ObjectID, Millisecond accuracy +# ulid ... Millisecond accuracy +# objectid ... This is left for backward compatibility + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# ID SETTINGS AFTER THAT! + +id: 'aidx' + +# ┌────────────────┐ +#───┘ Error tracking └────────────────────────────────────────── + +# Sentry is available for error tracking. +# See the Sentry documentation for more details on options. + +#sentryForBackend: +# enableNodeProfiling: true +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + +#sentryForFrontend: +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + +# Whether disable HSTS +#disableHsts: true + +# Number of worker processes +#clusterLimit: 1 + +# Job concurrency per worker +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 + +# Job rate limiter +# deliverJobPerSec: 128 +# inboxJobPerSec: 32 + +# Job attempts +# deliverJobMaxAttempts: 12 +# inboxJobMaxAttempts: 8 + +# IP address family used for outgoing request (ipv4, ipv6 or dual) +#outgoingAddressFamily: ipv4 + +# Proxy for HTTP/HTTPS +#proxy: http://127.0.0.1:3128 + +proxyBypassHosts: + - api.deepl.com + - api-free.deepl.com + - www.recaptcha.net + - hcaptcha.com + - challenges.cloudflare.com + +# Proxy for SMTP/SMTPS +#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT +#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 +#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 + +# Media Proxy +#mediaProxy: https://example.com/proxy + +# Proxy remote files (default: true) +proxyRemoteFiles: true + +# Sign to ActivityPub GET request (default: true) +signToActivityPubGet: true + +allowedPrivateNetworks: [ + '127.0.0.1/32' +] + +# Upload or download file size limits (bytes) +#maxFileSize: 262144000 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index d347882d1a91..3f8e5734ce87 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -106,6 +106,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index b11cbd137328..7080159117ac 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -172,6 +172,16 @@ redis: # # You can specify more ioredis options... # #username: example-username +#redisForReactions: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 +# # You can specify more ioredis options... +# #username: example-username + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index beefcfd0a2d5..3eb4fc28794b 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -103,6 +103,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 55fb1e6fa687..e02a533c1591 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -3,6 +3,8 @@ set -xe sudo chown node node_modules +sudo apt-get update +sudo apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb git config --global --add safe.directory /workspace git submodule update --init corepack install @@ -12,3 +14,4 @@ pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml pnpm build pnpm migrate +pnpm exec cypress install diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e78b82c47ccb..21a75f35442e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,3 +22,4 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md - [ ] (If needed) Add story of storybook - [ ] (If needed) Update CHANGELOG.md - [ ] (If possible) Add tests +- [ ] (If possible) Publish docker image diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index e7db18316cb3..8380a3bb238a 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -21,7 +21,7 @@ jobs: - run: corepack enable - name: Setup Node.js - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index d4e99f966ef0..44cc1a04f2c1 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout head uses: actions/checkout@v4.1.1 - name: Setup Node.js - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index 3a2a2d5f8dd7..5afd7d271487 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -28,7 +28,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/check-spdx-license-id.yml b/.github/workflows/check-spdx-license-id.yml index 6cd8bf60d520..05582008b5f2 100644 --- a/.github/workflows/check-spdx-license-id.yml +++ b/.github/workflows/check-spdx-license-id.yml @@ -48,12 +48,16 @@ jobs: "packages/backend/migration" "packages/backend/src" "packages/backend/test" + "packages/frontend-shared/@types" + "packages/frontend-shared/js" "packages/frontend/.storybook" "packages/frontend/@types" "packages/frontend/lib" "packages/frontend/public" "packages/frontend/src" "packages/frontend/test" + "packages/frontend-embed/@types" + "packages/frontend-embed/src" "packages/misskey-bubble-game/src" "packages/misskey-reversi/src" "packages/sw/src" diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 81e8134fb741..1bcaa0d9c487 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -33,7 +33,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c21fc951239f..07d9af12f7a0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,16 +8,24 @@ on: paths: - packages/backend/** - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-embed/** - packages/sw/** - packages/misskey-js/** + - packages/misskey-bubble-game/** + - packages/misskey-reversi/** - packages/shared/eslint.config.js - .github/workflows/lint.yml pull_request: paths: - packages/backend/** - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-embed/** - packages/sw/** - packages/misskey-js/** + - packages/misskey-bubble-game/** + - packages/misskey-reversi/** - packages/shared/eslint.config.js - .github/workflows/lint.yml jobs: @@ -29,7 +37,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.3 + - uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' cache: 'pnpm' @@ -40,22 +48,27 @@ jobs: needs: [pnpm_install] runs-on: ubuntu-latest continue-on-error: true - env: - eslint-cache-version: v1 strategy: matrix: workspace: - backend - frontend + - frontend-shared + - frontend-embed - sw - misskey-js + - misskey-bubble-game + - misskey-reversi + env: + eslint-cache-version: v1 + eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }} steps: - uses: actions/checkout@v4.1.1 with: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.3 + - uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' cache: 'pnpm' @@ -64,11 +77,10 @@ jobs: - name: Restore eslint cache uses: actions/cache@v4.0.2 with: - path: node_modules/.cache/eslint - key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}- - - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content + path: ${{ env.eslint-cache-path }} + key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}- + - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location ${{ env.eslint-cache-path }} --cache-strategy content typecheck: needs: [pnpm_install] @@ -78,6 +90,7 @@ jobs: matrix: workspace: - backend + - sw - misskey-js steps: - uses: actions/checkout@v4.1.1 @@ -85,14 +98,14 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.3 + - uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile - run: pnpm --filter misskey-js run build - if: ${{ matrix.workspace == 'backend' }} + if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }} - run: pnpm --filter misskey-reversi run build if: ${{ matrix.workspace == 'backend' }} - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index 95251bfe31e0..6bc8860a11eb 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.3 + - uses: actions/setup-node@v4.0.4 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 8dd9ed2513f5..ffaf7bc03833 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -26,7 +26,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/release-edit-with-push.yml b/.github/workflows/release-edit-with-push.yml index f86c1948f8b0..57657a4ba7b5 100644 --- a/.github/workflows/release-edit-with-push.yml +++ b/.github/workflows/release-edit-with-push.yml @@ -6,7 +6,7 @@ on: - develop paths: - 'CHANGELOG.md' - # - .github/workflows/release-edit-with-push.yml + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-with-dispatch.yml b/.github/workflows/release-with-dispatch.yml index 0936bc0ae8b9..ed2f822269a1 100644 --- a/.github/workflows/release-with-dispatch.yml +++ b/.github/workflows/release-with-dispatch.yml @@ -17,6 +17,10 @@ on: type: boolean description: 'MERGE RELEASE BRANCH TO MAIN' default: false + start-rc: + type: boolean + description: 'Start Release Candidate' + default: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -56,13 +60,13 @@ jobs: ### General - - + ### Client - - + ### Server - - + use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} secrets: @@ -79,6 +83,9 @@ jobs: package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} + draft_prerelease_channel: alpha + ready_start_prerelease_channel: beta + prerelease_channel: ${{ inputs.start-rc && 'rc' || '' }} secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -122,6 +129,7 @@ jobs: use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} stable_branch: ${{ vars.STABLE_BRANCH }} + draft_prerelease_channel: alpha secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-with-ready.yml b/.github/workflows/release-with-ready.yml index 79b6ade01285..e863b5e2e822 100644 --- a/.github/workflows/release-with-ready.yml +++ b/.github/workflows/release-with-ready.yml @@ -39,6 +39,8 @@ jobs: package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} + draft_prerelease_channel: alpha + ready_start_prerelease_channel: beta secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/report-api-diff.yml b/.github/workflows/report-api-diff.yml index df9cc279e866..1170f898ced9 100644 --- a/.github/workflows/report-api-diff.yml +++ b/.github/workflows/report-api-diff.yml @@ -70,18 +70,25 @@ jobs: - id: out-diff name: Build diff Comment run: | - cat <<- EOF > ./output.md - このPRによるapi.jsonの差分 -
- 差分はこちら - - \`\`\`diff - $(cat ./api.json.diff) - \`\`\` -
- - [Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) - EOF + HEADER="このPRによるapi.jsonの差分" + FOOTER="[Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + DIFF_BYTES="$(stat ./api.json.diff -c '%s' | tr -d '\n')" + + echo "$HEADER" > ./output.md + + if (( "$DIFF_BYTES" <= 1 )); then + echo '差分はありません。' >> ./output.md + else + echo '
' >> ./output.md + echo '差分はこちら' >> ./output.md + echo >> ./output.md + echo '```diff' >> ./output.md + cat ./api.json.diff >> ./output.md + echo '```' >> ./output.md + echo '
' >> .output.md + fi + + echo "$FOOTER" >> ./output.md - uses: thollander/actions-comment-pull-request@v2 with: pr_number: ${{ steps.load-pr-num.outputs.pr-number }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml deleted file mode 100644 index 68452aacaf88..000000000000 --- a/.github/workflows/storybook.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Storybook - -on: - push: - branches: - - master - - develop - - dev/storybook8 # for testing - pull_request_target: - -jobs: - build: - runs-on: ubuntu-latest - - env: - NODE_OPTIONS: "--max_old_space_size=7168" - - steps: - - uses: actions/checkout@v4.1.1 - if: github.event_name != 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - - uses: actions/checkout@v4.1.1 - if: github.event_name == 'pull_request_target' - with: - fetch-depth: 0 - submodules: true - ref: "refs/pull/${{ github.event.number }}/merge" - - name: Checkout actual HEAD - if: github.event_name == 'pull_request_target' - id: rev - run: | - echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT - git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3) - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.3 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: corepack enable - - run: pnpm i --frozen-lockfile - - name: Check pnpm-lock.yaml - run: git diff --exit-code pnpm-lock.yaml - - name: Build misskey-js - run: pnpm --filter misskey-js build - - name: Build storybook - run: pnpm --filter frontend build-storybook - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master' - run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name != 'pull_request_target' && github.ref != 'refs/heads/master' - id: chromatic_push - run: | - DIFF="${{ github.event.before }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - if pnpm --filter frontend chromatic -d storybook-static $(echo "$CHROMATIC_PARAMETER"); then - echo "success=true" >> $GITHUB_OUTPUT - else - echo "success=false" >> $GITHUB_OUTPUT - fi - env: - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Publish to Chromatic - if: github.event_name == 'pull_request_target' - id: chromatic_pull_request - run: | - DIFF="${{ steps.rev.outputs.base }} HEAD" - if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then - DIFF="HEAD" - fi - CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" - if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - fi - BRANCH="${{ github.event.pull_request.head.user.login }}:$HEAD_REF" - if [ "$BRANCH" = "misskey-dev:$HEAD_REF" ]; then - BRANCH="$HEAD_REF" - fi - pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name "$BRANCH" $(echo "$CHROMATIC_PARAMETER") - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: Notify that Chromatic detects changes - uses: actions/github-script@v7.0.1 - if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false' - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - github.rest.repos.createCommitComment({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: context.sha, - body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).' - }) - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: storybook - path: packages/frontend/storybook-static diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 026550025c95..d95d6676f9a0 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -46,7 +46,7 @@ jobs: - name: Install FFmpeg uses: FedericoCarboni/setup-ffmpeg@v3 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -93,7 +93,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index fcaef529695f..c68e1a8ef1d6 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -90,7 +90,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 9ad71919df15..63e81f8c92d1 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -31,7 +31,7 @@ jobs: - run: corepack enable - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 8ad8a6476696..0abc09c5a603 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -25,7 +25,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 06e987f27e4f..f809af1063d1 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -27,7 +27,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.3 + uses: actions/setup-node@v4.0.4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.gitignore b/.gitignore index 45170902b1de..b270d5cb3a36 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ coverage !/.config/example.yml !/.config/docker_example.yml !/.config/docker_example.env +!/.config/cypress-devcontainer.yml +docker-compose.yml +compose.yml .devcontainer/compose.yml !/.devcontainer/compose.yml @@ -42,6 +45,7 @@ coverage /build built built-test +js-built /data /.cache-loader /db @@ -61,6 +65,10 @@ temp tsdoc-metadata.json misskey-assets +# Vite temporary files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + # blender backups *.blend1 *.blend2 diff --git a/.node-version b/.node-version index 8ce7030825b5..1384ff6a1cbb 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.16.0 +22.5.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b996216ac174..db969a63c261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,106 @@ +## Unreleased + +### General +- + +### Client +- + +### Server +- + + +## 2024.9.0 + +### General +- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 + - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください +- Feat: パスキーでログインボタンを実装 (#14574) +- Feat: フォローされた際のメッセージを設定できるように +- Feat: 連合をホワイトリスト制にできるように +- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445) +- Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680) +- Feat: データエクスポートが完了した際に通知を発行するように +- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように +- Enhance: 依存関係の更新 +- Enhance: l10nの更新 + +### Client +- Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように +- Enhance: アイコンデコレーション管理画面にプレビューを追加 +- Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく +- Enhance: ScratchpadにUIインスペクターを追加 +- Enhance: Play編集画面の項目の並びを少しリデザイン +- Enhance: 各種メニューをドロワー表示するかどうか設定可能に +- Enhance: AiScriptのMk:C:containerのオプションに`borderStyle`と`borderRadius`を追加 +- Enhance: CWでも絵文字をクリックしてメニューを表示できるように +- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正 +- Fix: コントロールパネル内のAp requests内のチャートの表示がおかしかった問題を修正 +- Fix: 月の違う同じ日はセパレータが表示されないのを修正 +- Fix: タッチ画面でレンジスライダーを操作するとツールチップが複数表示される問題を修正 + (Cherry-picked from https://github.com/taiyme/misskey/pull/265) +- Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725) +- Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正 +- Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110) +- Fix: 一部画面のページネーションが動作しにくくなっていたのを修正 ( #12766 , #11449 ) + +### Server +- Feat: Misskey® Reactions Boost Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に +- Fix: アンテナの書き込み時にキーワードが与えられなかった場合のエラーをApiErrorとして投げるように + - この変更により、公式フロントエンドでは入力の不備が内部エラーとして報告される代わりに一般的なエラーダイアログで報告されます +- Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正 +- Fix: 外部ページを解析する際に、ページに紐づけられた関連リソースも読み込まれてしまう問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8) +- Fix: Continue importing from file if single emoji import fails +- Fix: `Retry-After`ヘッダーが送信されなかった問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/8a982c61c01909e7540ff1be9f019df07c3f0624) +- Fix: サーバーサイドのDOM解析完了時にリソースを開放するように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/634) +- Fix: ``を追って照会するのはOKレスポンスが返却された場合のみに + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/633) +- Fix: メールにスタイルが適用されていなかった問題を修正 + +## 2024.8.0 + +### General +- Enhance: モデレーターはすべてのユーザーのフォロー・フォロワーの一覧を見られるように +- Enhance: アカウントの削除のモデレーションログを残すように +- Enhance: 不適切なページ、ギャラリー、Playを管理者権限で削除できるように +- Fix: リモートユーザのフォロー・フォロワーの一覧が非公開設定の場合も表示できてしまう問題を修正 + +### Client +- Enhance: 「自分のPlay」ページにおいてPlayが非公開かどうかが一目でわかるように +- Enhance: 不適切なページ、ギャラリー、Playを通報できるように +- Fix: Play編集時に公開範囲が「パブリック」にリセットされる問題を修正 +- Fix: ページ遷移に失敗することがある問題を修正 +- Fix: iOSでユーザー名などがリンクとして誤検知される現象を抑制 +- Fix: mCaptchaを使用していてもbotプロテクションに関する警告が消えないのを修正 +- Fix: ユーザーのモデレーションページにおいてユーザー名にドットが入っているとシステムアカウントとして表示されてしまう問題を修正 +- Fix: 特定の条件下でノートの削除ボタンが出ないのを修正 + +### Server +- Enhance: 照会時にURLがhtmlかつheadタグ内に`rel="alternate"`, `type="application/activity+json"`の`link`タグがある場合に追ってリンク先を照会できるように +- Enhance: 凍結されたアカウントのフォローリクエストを表示しないように +- Fix: WSの`readAllNotifications` メッセージが `body` を持たない場合に動作しない問題 #14374 + - 通知ページや通知カラム(デッキ)を開いている状態において、新たに発生した通知が既読されない問題が修正されます。 + - これにより、プッシュ通知が有効な同条件下の環境において、プッシュ通知が常に発生してしまう問題も修正されます。 +- Fix: Play各種エンドポイントの返り値に`visibility`が含まれていない問題を修正 +- Fix: サーバー情報取得の際にモデレーター限定の情報が取得できないことがあるのを修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/582) +- Fix: 公開範囲がダイレクトのノートをユーザーアクティビティのチャート生成に使用しないように + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/679) +- Fix: ActivityPubのエンティティタイプ判定で不明なタイプを受け取った場合でも処理を継続するように + - キュー処理のつまりが改善される可能性があります +- Fix: リバーシの対局設定の変更が反映されないのを修正 +- Fix: 無制限にストリーミングのチャンネルに接続できる問題を修正 +- Fix: ベースロールのポリシーを変更した際にモデログに記録されないのを修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/700) +- Fix: Prevent memory leak from memory caches (#14310) +- Fix: More reliable memory cache eviction (#14311) + ## 2024.7.0 ### Note diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72c84c2f1845..f61311f1e594 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -572,3 +572,24 @@ marginはそのコンポーネントを使う側が設定する ### indexというファイル名を使うな ESMではディレクトリインポートは廃止されているのと、ディレクトリインポートせずともファイル名が index だと何故か一部のライブラリ?でディレクトリインポートだと見做されてエラーになる + +## CSS Recipe + +### Lighten CSS vars + +``` css +color: hsl(from var(--accent) h s calc(l + 10)); +``` + +### Darken CSS vars + +``` css +color: hsl(from var(--accent) h s calc(l - 10)); +``` + +### Add alpha to CSS vars + +``` css +color: color(from var(--accent) srgb r g b / 0.5); +``` + diff --git a/Changelog-neko.md b/Changelog-neko.md new file mode 100644 index 000000000000..908b329060e6 --- /dev/null +++ b/Changelog-neko.md @@ -0,0 +1,33 @@ +## Unreleased + +### General + +### Frontend + +### Backend + +### misskey-js + +### develop + +## 2024.9.0+neko-rc + +### General +- Fix: 프론트엔드의 타입 이슈 +- Feat: 노트 수정 기능 부활 +(cherry-pick from yunochi#4) +- Feat: 리모트 아바타 데코 연합 +(cherry-pick from yunochi#1 and 7dc3d230dda0f1ccac7e82e64c0cf4848994e1fc) +- Style: 기본 폰트 및 스킨셋 변경 +- Feat (locale): 어딘가에 묻혀 있던 로케일 추가 +- 각종 디펜던시 업데이트 및 수정 + +### Backend +- 최신 nsfwjs의 import 오류 대응 + +### Frontend +- Feat: "다른 계정 추가" 버튼 아래에 "새 계정 추가" 버튼이 살아 있어서 지웠습니다 +- Fix (test): 회원가입 관련 테스트 삭제 + +### develop +- QoL: `misskey-js`의 갱신과 이를 적용한 전체 빌드의 자동화 스크립트 추가 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e247bbcd775f..e21b2a31fcff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,9 @@ WORKDIR /misskey COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] COPY --link ["scripts", "./scripts"] COPY --link ["packages/backend/package.json", "./packages/backend/"] +COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] +COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] diff --git a/chart/files/default.yml b/chart/files/default.yml index f98b8ebfee04..120063df4cf6 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -124,6 +124,14 @@ redis: # #prefix: example-prefix # #db: 1 +#redisForReactions: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── @@ -208,6 +216,9 @@ id: "aidx" # Media Proxy #mediaProxy: https://example.com/proxy +# Proxy remote files (default: true) +proxyRemoteFiles: true + # Sign to ActivityPub GET request (default: true) signToActivityPubGet: true diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index d2525e0a7d05..7adfdb2fd6c1 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -49,49 +49,6 @@ describe('After setup instance', () => { it('successfully loads', () => { cy.visitHome(); - }); - - it('signup', () => { - cy.visitHome(); - - cy.intercept('POST', '/api/signup').as('signup'); - - cy.get('[data-cy-signup]').click(); - cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); - cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); - cy.get('[data-cy-modal-dialog-ok]').click(); - cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); - cy.get('[data-cy-signup-rules-continue]').click(); - - cy.get('[data-cy-signup-submit]').should('be.disabled'); - cy.get('[data-cy-signup-username] input').type('alice'); - cy.get('[data-cy-signup-submit]').should('be.disabled'); - cy.get('[data-cy-signup-password] input').type('alice1234'); - cy.get('[data-cy-signup-submit]').should('be.disabled'); - cy.get('[data-cy-signup-password-retype] input').type('alice1234'); - cy.get('[data-cy-signup-submit]').should('not.be.disabled'); - cy.get('[data-cy-signup-submit]').click(); - - cy.wait('@signup'); - }); - - it('signup with duplicated username', () => { - cy.registerUser('alice', 'alice1234'); - - cy.visitHome(); - - // ユーザー名が重複している場合の挙動確認 - cy.get('[data-cy-signup]').click(); - cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); - cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); - cy.get('[data-cy-modal-dialog-ok]').click(); - cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); - cy.get('[data-cy-signup-rules-continue]').click(); - - cy.get('[data-cy-signup-username] input').type('alice'); - cy.get('[data-cy-signup-password] input').type('alice1234'); - cy.get('[data-cy-signup-password-retype] input').type('alice1234'); - cy.get('[data-cy-signup-submit]').should('be.disabled'); }); }); diff --git a/idea/MkDisableSection.vue b/idea/MkDisableSection.vue new file mode 100644 index 000000000000..d177886569a2 --- /dev/null +++ b/idea/MkDisableSection.vue @@ -0,0 +1,41 @@ + + + + + + + diff --git a/idea/README.md b/idea/README.md new file mode 100644 index 000000000000..f64d16800a98 --- /dev/null +++ b/idea/README.md @@ -0,0 +1 @@ +使われなくなったけど消すのは勿体ない(将来使えるかもしれない)コードを入れておくとこ diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 6fb51ea5d860..0d9e4e116cf9 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -451,7 +451,6 @@ or: "অথবা" language: "ভাষা" uiLanguage: "UI এর ভাষা" aboutX: "{x} সম্পর্কে" -disableDrawer: "ড্রয়ার মেনু প্রদর্শন করবেন না" noHistory: "কোনো ইতিহাস নেই" signinHistory: "প্রবেশ করার ইতিহাস" doing: "প্রক্রিয়া করছে..." diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 7bd9a1bb32de..9d4ef016ceb1 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -60,6 +60,7 @@ copyFileId: "Copiar ID d'arxiu" copyFolderId: "Copiar ID de carpeta" copyProfileUrl: "Copiar URL del perfil" searchUser: "Cercar un usuari" +searchThisUsersNotes: "Cerca les publicacions de l'usuari" reply: "Respondre" loadMore: "Carregar més" showMore: "Veure més" @@ -108,11 +109,14 @@ enterEmoji: "Introduir un emoji" renote: "Impulsa" unrenote: "Anul·la l'impuls" renoted: "S'ha impulsat" +renotedToX: "Impulsat per {name}." cantRenote: "No es pot impulsar aquesta publicació" cantReRenote: "No es pot impulsar l'impuls." quote: "Cita" inChannelRenote: "Renotar només al Canal" inChannelQuote: "Citar només al Canal" +renoteToChannel: "Impulsa a un canal" +renoteToOtherChannel: "Impulsa a un altre canal" pinnedNote: "Nota fixada" pinned: "Fixar al perfil" you: "Tu" @@ -151,6 +155,7 @@ editList: "Editar llista" selectChannel: "Selecciona un canal" selectAntenna: "Tria una antena" editAntenna: "Modificar antena" +createAntenna: "Crea una antena" selectWidget: "Triar un giny" editWidgets: "Editar ginys" editWidgetsExit: "Fet" @@ -177,6 +182,10 @@ addAccount: "Afegeix un compte" reloadAccountsList: "Recarregar la llista de contactes" loginFailed: "S'ha produït un error al accedir." showOnRemote: "Navega més en el perfil original" +continueOnRemote: "Veure perfil original" +chooseServerOnMisskeyHub: "Escull un servidor des del Hub de Misskey" +specifyServerHost: "Especifica un servidor directament" +inputHostName: "Introdueix el domini" general: "General" wallpaper: "Fons de Pantalla" setWallpaper: "Defineix el fons de pantalla" @@ -187,6 +196,7 @@ followConfirm: "Estàs segur que vols deixar de seguir {name}?" proxyAccount: "Compte de proxy" proxyAccountDescription: "Un compte proxy és un compte que actua com a seguidor remot per als usuaris en determinades condicions. Per exemple, quan un usuari afegeix un usuari remot a la llista, l'activitat de l'usuari remot no es lliurarà al servidor si cap usuari local segueix aquest usuari, de manera que el compte proxy el seguirà." host: "Amfitrió" +selectSelf: "Escollir manualment" selectUser: "Selecciona usuari/a" recipient: "Destinatari" annotation: "Comentaris" @@ -202,6 +212,7 @@ perDay: "Per dia" stopActivityDelivery: "Deixa d'enviar activitats" blockThisInstance: "Deixa d'enviar activitats" silenceThisInstance: "Silencia aquesta instància " +mediaSilenceThisInstance: "Silenciar els arxius d'aquesta instància " operations: "Accions" software: "Programari" version: "Versió" @@ -223,6 +234,8 @@ blockedInstances: "Instàncies bloquejades" blockedInstancesDescription: "Llista els enllaços d'amfitrió de les instàncies que vols bloquejar separades per un salt de pàgina. Les instàncies llistades no podran comunicar-se amb aquesta instància." silencedInstances: "Instàncies silenciades" silencedInstancesDescription: "Llista els enllaços d'amfitrió de les instàncies que vols silenciar. Tots els comptes de les instàncies llistades s'establiran com silenciades i només podran fer sol·licitacions de seguiment, i no podran mencionar als comptes locals si no els segueixen. Això no afectarà les instàncies bloquejades." +mediaSilencedInstances: "Instàncies amb els arxius silenciats" +mediaSilencedInstancesDescription: "Llista els noms dels servidors que vulguis silenciar els arxius, un servidor per línia. Tots els comptes que pertanyin als servidors llistats seran tractats com sensibles i no podran fer servir emojis personalitzats. Això no tindrà efecte sobre els servidors blocats." muteAndBlock: "Silencia i bloca" mutedUsers: "Usuaris silenciats" blockedUsers: "Usuaris bloquejats" @@ -313,6 +326,7 @@ selectFile: "Selecciona fitxers" selectFiles: "Selecciona fitxers" selectFolder: "Selecció de carpeta" selectFolders: "Selecció de carpeta" +fileNotSelected: "Cap fitxer seleccionat" renameFile: "Canvia el nom del fitxer" folderName: "Nom de la carpeta" createFolder: "Crea una carpeta" @@ -468,10 +482,12 @@ retype: "Torneu a introduir-la" noteOf: "Publicació de: {user}" quoteAttached: "Frase adjunta" quoteQuestion: "Vols annexar-la com a cita?" +attachAsFileQuestion: "El text copiat és massa llarg. Vols adjuntar-lo com un fitxer de text?" noMessagesYet: "Encara no hi ha missatges" newMessageExists: "Has rebut un nou missatge" onlyOneFileCanBeAttached: "Només pots adjuntar un fitxer a un missatge" signinRequired: "Si us plau, Registra't o inicia la sessió abans de continuar" +signinOrContinueOnRemote: "Per continuar necessites moure el teu servidor o registrar-te / iniciar sessió en aquest servidor." invitations: "Convida" invitationCode: "Codi d'invitació" checking: "Comprovació en curs..." @@ -493,7 +509,6 @@ uiLanguage: "Idioma de l'interfície" aboutX: "Respecte a {x}" emojiStyle: "Estil d'emoji" native: "Nadiu" -disableDrawer: "No mostrar els menús en calaixos" showNoteActionsOnlyHover: "Només mostra accions de la nota en passar amb el cursor" showReactionsCount: "Mostra el nombre de reaccions a les publicacions" noHistory: "No hi ha un registre previ" @@ -543,7 +558,7 @@ objectStorageUseSSLDesc: "Desactiva'l si no tens pensat fer servir HTTPS per les objectStorageUseProxy: "Connectar-se mitjançant un Proxy" objectStorageUseProxyDesc: "Desactiva'l si no faràs servir un Proxy per les connexions de l'API" objectStorageSetPublicRead: "Configurar les pujades com públiques " -s3ForcePathStyleDesc: "Si s3ForcePathStyle es troba activat el nom del dipòsit s'ha d'incloure a l'adreça URL en comtes del nom del host. Potser que necessitis activar-ho quan facis servir, per exemple, Minio a un servidor propi." +s3ForcePathStyleDesc: "Si s3ForcePathStyle es troba activat el nom del cubell s'haurà d'especificar com a part de l'adreça URL en comptes del nom del servidor. Podria ser que necessitis activar aquesta opció quan facis servir serveis com ara l'allotjament a un servidor propi." serverLogs: "Registres del servidor" deleteAll: "Elimina-ho tot" showFixedPostForm: "Mostrar el formulari per escriure a l'inici de la línia de temps" @@ -576,6 +591,8 @@ ascendingOrder: "Ascendent" descendingOrder: "Descendent" scratchpad: "Bloc de proves" scratchpadDescription: "El bloc de proves proporciona un entorn experimental per AiScript. Pot escriure i verificar els resultats que interactuen amb Misskey." +uiInspector: "Inspector de la interfície" +uiInspectorDescription: "Podeu visualitzar una llista d'elements UI presents en la memòria. Els components de la interfície d'usuari són generats per les funcions Ui:C:." output: "Sortida" script: "Script" disablePagesScript: "Desactivar AiScript a les pàgines " @@ -832,6 +849,7 @@ administration: "Administració" accounts: "Comptes" switch: "Canvia" noMaintainerInformationWarning: "La informació de l'administrador no s'ha configurat" +noInquiryUrlWarning: "No s'ha desat l'URL de consulta." noBotProtectionWarning: "La protecció contra bots no s'ha configurat." configure: "Configurar" postToGallery: "Crear una nova publicació a la galeria" @@ -1021,6 +1039,7 @@ thisPostMayBeAnnoyingHome: "Publicar a la línia de temps d'Inici" thisPostMayBeAnnoyingCancel: "Cancel·lar " thisPostMayBeAnnoyingIgnore: "Publicar de totes maneres" collapseRenotes: "Col·lapsar les renotes que ja has vist" +collapseRenotesDescription: "Col·lapse les notes a les quals ja has reaccionat o que ja has renotat" internalServerError: "Error intern del servidor" internalServerErrorDescription: "El servidor ha fallat de manera inexplicable." copyErrorInfo: "Copiar la informació de l'error " @@ -1094,6 +1113,8 @@ preservedUsernames: "Noms d'usuaris reservats" preservedUsernamesDescription: "Llistat de noms d'usuaris que no es poden fer servir separats per salts de linia. Aquests noms d'usuaris no estaran disponibles quan es creï un compte d'usuari normal, però els administradors els poden fer servir per crear comptes manualment. Per altre banda els comptes ja creats amb aquests noms d'usuari no es veure'n afectats." createNoteFromTheFile: "Compon una nota des d'aquest fitxer" archive: "Arxiu" +archived: "Arxivat" +unarchive: "Desarxivar" channelArchiveConfirmTitle: "Vols arxivar {name}?" channelArchiveConfirmDescription: "Un Canal arxivat no apareixerà a la llista de canals o als resultats de cerca. Tampoc es poden afegir noves entrades." thisChannelArchived: "Aquest Canal ha sigut arxivat." @@ -1104,6 +1125,9 @@ preventAiLearning: "Descartar l'ús d'aprenentatge automàtic (IA Generativa)" preventAiLearningDescription: "Demanar els indexadors no fer servir els texts, imatges, etc. en cap conjunt de dades per alimentar l'aprenentatge automàtic (IA Predictiva/ Generativa). Això s'aconsegueix afegint la etiqueta \"noai\" com a resposta HTML al contingut corresponent. Prevenir aquest ús totalment pot ser que no sigui aconseguit, ja que molts indexadors poden obviar aquesta etiqueta." options: "Opcions" specifyUser: "Especificar usuari" +lookupConfirm: "Vols fer una cerca?" +openTagPageConfirm: "Vols obrir una pàgina d'etiquetes?" +specifyHost: "Especifica un servidor" failedToPreviewUrl: "Vista prèvia no disponible" update: "Actualitzar" rolesThatCanBeUsedThisEmojiAsReaction: "Rols que poden fer servir aquest emoji com a reacció " @@ -1172,7 +1196,10 @@ confirmShowRepliesAll: "Aquesta opció no té marxa enrere. Vols mostrar les tev confirmHideRepliesAll: "Aquesta opció no té marxa enrere. Vols ocultar les teves respostes a tots els usuaris que segueixes a la línia de temps?" externalServices: "Serveis externs" sourceCode: "Codi font" +sourceCodeIsNotYetProvided: "El codi font encara no es troba disponible. Contacta amb l'administrador per solucionar aquest problema." repositoryUrl: "URL del repositori" +repositoryUrlDescription: "Si estàs fent servir Misskey tal com és (sense cap canvi al codi font), introdueix https://github.com/misskey-dev/misskey" +repositoryUrlOrTarballRequired: "Si no ofereixes cap repositori, publica un fitxer tarball. Dona una ullada a .config/example.yml per a més informació." feedback: "Opinió" feedbackUrl: "URL per a opinar" impressum: "Impressum" @@ -1211,6 +1238,7 @@ showReplay: "Veure reproducció" replay: "Reproduir" replaying: "Reproduint" endReplay: "Tanca la redifusió" +copyReplayData: "Copia les dades de la resposta" ranking: "Classificació" lastNDays: "Últims {n} dies" backToTitle: "Torna al títol" @@ -1224,12 +1252,42 @@ gameRetry: "Torna a provar" notUsePleaseLeaveBlank: "Si no voleu usar-ho, deixeu-ho en blanc" useTotp: "Usa una contrasenya d'un sol ús" useBackupCode: "Usa un codi de recuperació" +launchApp: "Inicia l'aplicació " +useNativeUIForVideoAudioPlayer: "Fes servir la UI del navegador quan reprodueixis vídeo i àudio " +keepOriginalFilename: "Desa el nom del fitxer original" +keepOriginalFilenameDescription: "Si desactives aquesta opció els noms dels fitxers se substituiran per una cadena aleatòria quan carreguis nous fitxers de forma automàtica." +noDescription: "No hi ha una descripció " +alwaysConfirmFollow: "Confirma sempre els seguiments" +inquiry: "Contacte" +tryAgain: "Intenta-ho més tard." +confirmWhenRevealingSensitiveMedia: "Confirmació quan revelis contingut sensible " +sensitiveMediaRevealConfirm: "Aquest contingut potser sensible. Segur que ho vols revelar?" +createdLists: "Llistes creades " +createdAntennas: "Antenes creades" +fromX: "De {x}" +genEmbedCode: "Obtenir el codi per incrustar" +noteOfThisUser: "Notes d'aquest usuari" +clipNoteLimitExceeded: "No es poden afegir més notes a aquest clip." _delivery: + status: "Estat d'entrega " stop: "Suspés" + resume: "Torna a enviar" _type: none: "S'està publicant" + manuallySuspended: "Suspendre manualment" + goneSuspended: "Servidor suspès perquè el servidor s'ha esborrat" + autoSuspendedForNotResponding: "Servidor suspès perquè el servidor no respon" _bubbleGame: howToPlay: "Com es juga" + hold: "Mantenir" + _score: + score: "Puntuació " + scoreYen: "Diners guanyats" + highScore: "Millor puntuació " + maxChain: "Nombre màxim de combos" + yen: "{yen}Ien" + estimatedQty: "{qty}peces" + scoreSweets: "{onigiriQtyWithUnit}ongiris" _howToPlay: section1: "Ajusta la posició i deixa caure l'objecte dintre la caixa." section2: "Quan dos objectes del mateix tipus es toquen, canviaran en un objecte diferent i guanyares punts." @@ -1344,6 +1402,9 @@ _serverSettings: fanoutTimelineDescription: "Quan es troba activat millora bastant el rendiment quan es recuperen les línies de temps i redueix la carrega de la base de dades. Com a contrapunt, l'ús de memòria de Redis es veurà incrementada. Considera d'estabilitat aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes de inestabilitat." fanoutTimelineDbFallback: "Carregar de la base de dades" fanoutTimelineDbFallbackDescription: "Quan s'activa, la línia de temps fa servir la base de dades per consultes adicionals si la línia de temps no es troba a la memòria cau. Si és desactiva la càrrega del servidor és veure reduïda, però també és reduirà el nombre de línies de temps que és poden obtenir." + reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat." + inquiryUrl: "URL de consulta " + inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació." _accountMigration: moveFrom: "Migrar un altre compte a aquest" moveFromSub: "Crear un àlies per un altre compte" @@ -1651,6 +1712,7 @@ _role: gtlAvailable: "Pot veure la línia de temps global" ltlAvailable: "Pot veure la línia de temps local" canPublicNote: "Pot enviar notes públiques" + mentionMax: "Nombre màxim de mencions a una nota" canInvite: "Pot crear invitacions a la instància " inviteLimit: "Límit d'invitacions " inviteLimitCycle: "Temps de refresc de les invitacions" @@ -1659,6 +1721,7 @@ _role: canManageAvatarDecorations: "Gestiona les decoracions dels avatars " driveCapacity: "Capacitat del disc" alwaysMarkNsfw: "Marca sempre els fitxers com a sensibles" + canUpdateBioMedia: "Permet l'edició d'una icona o un bàner" pinMax: "Nombre màxim de notes fixades" antennaMax: "Nombre màxim d'antenes" wordMuteMax: "Nombre màxim de caràcters permesos a les paraules silenciades" @@ -1673,9 +1736,20 @@ _role: canSearchNotes: "Pot cercar notes" canUseTranslator: "Pot fer servir el traductor" avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars" + canImportAntennas: "Autoritza la importació d'antenes " + canImportBlocking: "Autoritza la importació de bloquejats" + canImportFollowing: "Autoritza la importació de seguidors" + canImportMuting: "Autoritza la importació de silenciats" + canImportUserLists: "Autoritza la importació de llistes d'usuaris " _condition: + roleAssignedTo: "Assignat a rols manuals" isLocal: "Usuari local" isRemote: "Usuari remot" + isCat: "Usuaris gats" + isBot: "Usuaris bots" + isSuspended: "Usuari suspès" + isLocked: "Comptes privats" + isExplorable: "Fes que el compte aparegui a les cerques" createdLessThan: "Han passat menys de X a passat des de la creació del compte" createdMoreThan: "Han passat més de X des de la creació del compte" followersLessThanOrEq: "Té menys de X seguidors" @@ -1745,6 +1819,7 @@ _plugin: installWarn: "Si us plau, no instal·lis afegits que no siguin de confiança." manage: "Gestionar els afegits" viewSource: "Veure l'origen " + viewLog: "Mostra el registre" _preferencesBackups: list: "Llista de còpies de seguretat" saveNew: "Fer una còpia de seguretat nova" @@ -1774,6 +1849,8 @@ _aboutMisskey: contributors: "Col·laboradors principals" allContributors: "Tots els col·laboradors " source: "Codi font" + original: "Original" + thisIsModifiedVersion: "En {name} fa servir una versió modificada de Misskey." translation: "Tradueix Misskey" donate: "Fes un donatiu a Misskey" morePatrons: "També agraïm el suport d'altres col·laboradors que no surten en aquesta llista. Gràcies! 🥰" @@ -1901,6 +1978,7 @@ _soundSettings: driveFileTypeWarnDescription: "Seleccionar un fitxer d'àudio " driveFileDurationWarn: "L'àudio és massa llarg" driveFileDurationWarnDescription: "Els àudios molt llargs pot interrompre l'ús de Misskey. Vols continuar?" + driveFileError: "El so no es pot carregar. Canvia la configuració" _ago: future: "Futur " justNow: "Ara mateix" @@ -1953,6 +2031,7 @@ _2fa: backupCodesDescription: "Si l'aplicació d'autenticació no es pot utilitzar, es pot accedir al compte utilitzant els següents codis de còpia de seguretat. Assegura't de mantenir aquests codis en un lloc segur. Cada codi es pot utilitzar només una vegada." backupCodeUsedWarning: "Es va utilitzar un codi de còpia de seguretat. Si l'aplicació de certificació està disponible, reconfigura l'aplicació d'autenticació tan aviat com sigui possible." backupCodesExhaustedWarning: "Es van utilitzar tots els codis de còpia de seguretat. Si no es pot utilitzar l'aplicació d'autenticació, ja no es pot accedir al compte. Torna a registrar l'aplicació d'autenticació." + moreDetailedGuideHere: "Aquí tens una guia al detall" _permissions: "read:account": "Veure la informació del compte." "write:account": "Editar la informació del compte." @@ -2026,22 +2105,73 @@ _permissions: "read:admin:emoji": "Veure emojis" "write:admin:queue": "Gestionar la cua de feines" "read:admin:queue": "Veure la cua de feines" + "write:admin:promo": "Gestiona les notes promocionals" + "write:admin:drive": "Gestiona el disc de l'usuari" + "read:admin:drive": "Veure la informació del disc de l'usuari" + "read:admin:stream": "Fes servir l'API sobre Websocket per l'administració" + "write:admin:ad": "Gestiona la publicitat" + "read:admin:ad": "Veure anuncis" + "write:invite-codes": "Crear codis d'invitació" + "read:invite-codes": "Obtenir codis d'invitació" + "write:clip-favorite": "Gestionar els clips favorits" + "read:clip-favorite": "Veure clips favorits" + "read:federation": "Veure dades de federació" + "write:report-abuse": "Informar d'un abús" +_auth: + shareAccessTitle: "Concedeix permisos a l'aplicació" + shareAccess: "Vols que {name} pugui accedir al vostre compte?" + shareAccessAsk: "Segur que vols que aquesta aplicació pugui accedir al vostre compte?" + permission: "{name} demana els següents permisos" + permissionAsk: "Aquesta aplicació demana els següents permisos" + pleaseGoBack: "Si us plau, torna a l'aplicació" + callback: "Tornant a l'aplicació" + denied: "Accés denegat" + pleaseLogin: "Si us plau, identificat per autoritzar l'aplicació." _antennaSources: all: "Totes les publicacions" homeTimeline: "Publicacions dels usuaris seguits" users: "Publicacions d'usuaris específics" userList: "Publicacions d'una llista d'usuaris" + userBlacklist: "Totes les notes excepte les d'un o alguns usuaris especificats" +_weekday: + sunday: "Diumenge" + monday: "Dilluns" + tuesday: "Dimarts" + wednesday: "Dimecres" + thursday: "Dijous" + friday: "Divendres" + saturday: "Dissabte" _widgets: profile: "Perfil" instanceInfo: "Informació del fitxer d'instal·lació" + memo: "Notes adhesives" notifications: "Notificacions" timeline: "Línia de temps" + calendar: "Calendari" + trends: "Tendència" + clock: "Rellotge" + rss: "Lector RSS" + rssTicker: "RSS ticker" activity: "Activitat" + photos: "Fotografies" + digitalClock: "Rellotge digital" + unixClock: "Rellotge UNIX" federation: "Federació" + instanceCloud: "Núvol d'instàncies" + postForm: "Formulari de publicació" + slideshow: "Presentació" button: "Botó " + onlineUsers: "Usuaris actius" jobQueue: "Cua de tasques" + serverMetric: "Mètriques del servidor" + aiscript: "Consola AiScript" + aiscriptApp: "Aplicació AiScript" + aichan: "Ai" + userList: "Llistat d'usuaris" _userList: chooseList: "Tria una llista" + clicker: "Clicker" + birthdayFollowings: "Usuaris que fan l'aniversari avui" _cw: hide: "Amagar" show: "Carregar més" @@ -2107,25 +2237,74 @@ _profile: avatarDecorationMax: "Pot afegir un màxim de {max} decoracions." _exportOrImport: allNotes: "Totes les publicacions" + favoritedNotes: "Notes preferides" clips: "Retalls" followingList: "Seguint" muteList: "Silencia" blockingList: "Bloqueja" userLists: "Llistes" + excludeMutingUsers: "Exclou usuaris silenciats" + excludeInactiveUsers: "Exclou usuaris inactius" + withReplies: "Inclou a la línia de temps les respostes d'usuaris importats" _charts: federation: "Federació" + apRequest: "Peticions" + usersIncDec: "Diferència entre el nombre d'usuaris" + usersTotal: "Nombre total d'usuaris" + activeUsers: "Usuaris actius" + notesIncDec: "Diferència entre el nombre de notes" + localNotesIncDec: "Diferencia en el nombre de notes locals" + remoteNotesIncDec: "Diferencia en el nombre de notes remotes" + notesTotal: "Nombre total de notes" + filesIncDec: "Diferencia en el nombre de fitxers" + filesTotal: "Nombre total de fitxers" + storageUsageIncDec: "Diferencia en l'emmagatzematge usat" + storageUsageTotal: "Emmagatzematge usat" +_instanceCharts: + requests: "Peticions" + users: "Diferència entre el nombre d'usuaris" + usersTotal: "Usuaris totals acumulats" + notes: "Diferència entre el nombre de notes" + notesTotal: "Notes totals acumulades" + ff: "Diferència en nombre d'usuaris seguits / seguidors" + ffTotal: "Nombre total acumulat d'usuaris seguits / seguidors" + cacheSize: "Diferència a la mida de la memòria cau" + cacheSizeTotal: "Total acumulat de la mida de la memòria cau" + files: "Diferència al nombre d'arxius" + filesTotal: "Nombre acumulatiu de fitxers" _timelines: home: "Inici" local: "Local" social: "Social" global: "Global" _play: + new: "Crear un guió" + edit: "Editar guió" + created: "Guió creat" + updated: "Guió editat" + deleted: "Guió esborrat" + pageSetting: "Configuració del guió" + editThisPage: "Edita aquest guió" viewSource: "Veure l'origen " + my: "Els meus guions" + liked: "Guions que m'han agradat" featured: "Popular" title: "Títol " script: "Script" summary: "Descripció" + visibilityDescription: "" _pages: + newPage: "pa" + editPage: "Editar la pàgina" + readPage: "Veure el codi font d'aquesta pàgina" + created: "La pàgina ha sigut creada correctament" + updated: "La pàgina s'ha editat correctament" + deleted: "La pàgina s'ha esborrat sense problemes" + pageSetting: "Configuració de la pàgina" + nameAlreadyExists: "L'adreça URL de la pàgina ja existeix" + invalidNameTitle: "L'adreça URL de la pàgina no és vàlida" + invalidNameText: "Assegurat que el títol de la pàgina no és buit" + editThisPage: "Editar la pàgina" viewSource: "Veure l'origen " viewPage: "Veure les teves pàgines " like: "M'agrada " @@ -2148,6 +2327,7 @@ _pages: eyeCatchingImageSet: "Escull una miniatura" eyeCatchingImageRemove: "Esborrar la miniatura" chooseBlock: "Afegeix un bloc" + enterSectionTitle: "Escriu el títol de la secció" selectType: "Seleccionar tipus" contentBlocks: "Contingut" inputBlocks: "Entrada " @@ -2158,6 +2338,8 @@ _pages: section: "Secció " image: "Imatges" button: "Botó " + dynamic: "Blocs dinàmics" + dynamicDescription: "Aquest bloc és antic. Ara en endavant fes servir {play}" note: "Incorporar una Nota" _note: id: "ID de la publicació" @@ -2187,29 +2369,50 @@ _notification: sendTestNotification: "Enviar notificació de prova" notificationWillBeDisplayedLikeThis: "Les notificacions és veure'n així " reactedBySomeUsers: "Han reaccionat {n} usuaris" + likedBySomeUsers: "A {n} usuaris els hi agrada la teva nota" renotedBySomeUsers: "L'han impulsat {n} usuaris" + followedBySomeUsers: "Et segueixen {n} usuaris" + flushNotification: "Netejar notificacions" _types: all: "Tots" + note: "Notes noves" follow: "Seguint" mention: "Menció" + reply: "Respostes" renote: "Renotar" quote: "Citar" reaction: "Reaccions" + pollEnded: "Enquesta terminada" + receiveFollowRequest: "Rebuda una petició de seguiment" + followRequestAccepted: "Petició de seguiment acceptada" + roleAssigned: "Rol donat" + achievementEarned: "Assoliment desbloquejat" + app: "Notificacions d'aplicacions" _actions: followBack: "t'ha seguit també" reply: "Respondre" renote: "Renotar" _deck: + alwaysShowMainColumn: "Mostrar sempre la columna principal" columnAlign: "Alinea les columnes" addColumn: "Afig una columna" + newNoteNotificationSettings: "Configuració de notificacions per a notes noves" + configureColumn: "Configuració de columnes" swapLeft: "Mou a l’esquerra" swapRight: "Mou a la dreta" swapUp: "Mou cap amunt" swapDown: "Mou cap avall" + stackLeft: "Pila a la columna esquerra" popRight: "Col·loca a la dreta" profile: "Perfil" newProfile: "Perfil nou" deleteProfile: "Elimina el perfil" + introduction: "Crea la interfície perfecta posant les columnes allà on vulguis!" + introduction2: "Fes clic al botó + de la dreta per afegir noves columnes sempre que vulguis." + widgetsIntroduction: "Selecciona \"Editar ginys\" a la columna del menú i afegeix un." + useSimpleUiForNonRootPages: "Usa una interfície senzilla per a les pàgines navegades" + usedAsMinWidthWhenFlexible: "L'amplada mínima es farà servir quan \"Ajust automàtic de l'amplada\" estigui activat" + flexible: "Ajust automàtic de l'amplada" _columns: main: "Principal" widgets: "Ginys" @@ -2220,18 +2423,77 @@ _deck: channel: "Canals" mentions: "Mencions" direct: "Publicacions directes" + roleTimeline: "Línia de temps dels rols" +_dialog: + charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}" + charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}" +_disabledTimeline: + title: "Línia de tems desactivada" + description: "No pots fer servir aquesta línia de temps amb els teus rols actuals." +_drivecleaner: + orderBySizeDesc: "Mida del fitxer descendent" + orderByCreatedAtAsc: "Data ascendent" _webhookSettings: + createWebhook: "Crear un Webhook" + modifyWebhook: "Modificar un Webhook" name: "Nom" + secret: "Secret" + trigger: "Activador" active: "Activat" + _events: + follow: "Quan se segueix a un usuari" + followed: "Quan et segueixen" + note: "Quan es publica una nota" + reply: "Quan es rep una resposta" + renote: "Quan es renoti" + reaction: "Quan es rep una reacció " + mention: "Quan et mencionen" + _systemEvents: + abuseReport: "Quan reps un nou informe de moderació " + abuseReportResolved: "Quan resols un informe de moderació " + userCreated: "Quan es crea un usuari" + deleteConfirm: "Segur que vols esborrar el webhook?" + testRemarks: "Si feu clic al botó a la dreta de l'interruptor, podeu enviar un webhook de prova amb dades dummy." _abuseReport: _notificationRecipient: + createRecipient: "Afegeix un destinatari a l'informe de moderació " + modifyRecipient: "Editar un destinatari en l'informe de moderació " + recipientType: "Tipus de notificació " _recipientType: mail: "Correu electrònic" + webhook: "Webhook" + _captions: + mail: "Enviar un correu electrònic a tots els moderadors quan es rep un informe de moderació " + webhook: "Enviar una notificació al SystemWebhook quan es rebi o es resolgui un informe de moderació " + keywords: "Paraules clau" + notifiedUser: "Usuaris que s'han de notificar " + notifiedWebhook: "Webhook que s'ha de fer servir" + deleteConfirm: "Segur que vols esborrar el destinatari de l'informe de moderació?" _moderationLogTypes: + createRole: "Rol creat" + deleteRole: "Rol esborrat" + updateRole: "Rol actualitzat" + assignRole: "Assignat al rol" + unassignRole: "Esborrat del rol" suspend: "Suspèn" + unsuspend: "Suspensió treta" + addCustomEmoji: "Afegit emoji personalitzat" + updateCustomEmoji: "Actualitzat emoji personalitzat" + deleteCustomEmoji: "Esborrat emoji personalitzat" + updateServerSettings: "Configuració del servidor actualitzada" + updateUserNote: "Nota de moderació actualitzada" + deleteDriveFile: "Fitxer esborrat" + deleteNote: "Nota esborrada" + createGlobalAnnouncement: "Anunci global creat" + createUserAnnouncement: "Anunci individual creat" + updateGlobalAnnouncement: "Anunci global actualitzat" + updateUserAnnouncement: "Anunci individual actualitzat " + deleteGlobalAnnouncement: "Anunci global esborrat" + deleteUserAnnouncement: "Anunci individual esborrat " resetPassword: "Restableix la contrasenya" suspendRemoteInstance: "Servidor remot suspès " unsuspendRemoteInstance: "S'ha tret la suspensió del servidor remot" + updateRemoteInstanceNote: "Nota de moderació de la instància remota actualitzada" markSensitiveDriveFile: "Fitxer marcat com a sensible" unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer" resolveAbuseReport: "Informe resolt" @@ -2244,6 +2506,16 @@ _moderationLogTypes: deleteAvatarDecoration: "S'ha esborrat la decoració de l'avatar " unsetUserAvatar: "Esborrar l'avatar d'aquest usuari" unsetUserBanner: "Esborrar el bàner d'aquest usuari" + createSystemWebhook: "Crear un SystemWebhook" + updateSystemWebhook: "Actualitzar SystemWebhook" + deleteSystemWebhook: "Esborrar SystemWebhook" + createAbuseReportNotificationRecipient: "Crear un destinatari per l'informe de moderació " + updateAbuseReportNotificationRecipient: "Actualitzar destinatari per l'informe de moderació " + deleteAbuseReportNotificationRecipient: "Esborrar destinatari de l'informe de moderació " + deleteAccount: "Esborrar el compte " + deletePage: "Esborrar la pàgina" + deleteFlash: "Esborrar el guió" + deleteGalleryPost: "Esborrar la publicació de la galeria" _fileViewer: title: "Detall del fitxer" type: "Tipus de fitxer" @@ -2270,5 +2542,54 @@ _externalResourceInstaller: _errors: _invalidParams: title: "Paràmetres no vàlids " + description: "No hi ha suficient informació per carregar les dades del lloc extern. Confirma l'URL que hi ha escrita." + _resourceTypeNotSupported: + title: "El recurs extern no està suportat." + description: "Aquesta mena de recurs no està suportat. Contacta amb l'administrador." + _failedToFetch: + title: "Ha fallat l'obtenció de dades" + fetchErrorDescription: "Ha aparegut un error comunicant-se amb el lloc extern. Si després d'intentar-ho un altre cop no es resol, contacta amb l'administrador." + parseErrorDescription: "Ha aparegut un error processant les dades carregades del lloc extern. Contacta amb l'administrador." + _hashUnmatched: + title: "Ha fallat la verificació de les dades" + description: "Ha aparegut un error verificant les dades obtingudes. Com a mesura de seguretat la instal·lació no pot continuar. Contacta amb l'administrador." + _pluginParseFailed: + title: "Error d'AiScript" + description: "Les dades sol·licitades s'han obtingut correctament, però hem trobat un error durant el processament d'AiScript. Contacta amb l'autor de l'afegit. Detalls de l'error es pot veure a la consola JavaScript." + _pluginInstallFailed: + title: "La instal·lació de l'afegit a fallat" + description: "Ha aparegut un error durant la instal·lació de l'afegit. Intenta-ho una altra vegada. El detall de l'error es pot veure a la consola JavaScript." + _themeParseFailed: + title: "Ha fallat el processament del tema" + description: "Les dades sol·licitades s'han obtingut correctament, però hem trobat un error durant el processament del tema. Contacta amb l'autor de l'afegit. Detalls de l'error es pot veure a la consola JavaScript." + _themeInstallFailed: + title: "La instal·lació del tema a fallat" + description: "Ha aparegut un error durant la instal·lació del tema. Intenta-ho una altra vegada. El detall de l'error es pot veure a la consola JavaScript." +_dataSaver: + _media: + title: "Carregant multimèdia " + description: "Desactiva la càrrega automàtica d'imatges i vídeos. Les imatges i els vídeos amagats es carregaran quan es faci clic a sobre." + _avatar: + title: "Avatars animats" + description: "Detenir l'animació dels avatars animats. Les imatges animades solen tenir un pes més gran que les imatges normals, reduint el tràfic disponible." + _urlPreview: + title: "Miniatures vista prèvia de l'URL" + description: "Les imatges en miniatura que serveixen com a vista prèvia de les URLs no es tornaran a carregar." + _code: + title: "Ressaltat del codi " _reversi: total: "Total" +_embedCodeGen: + title: "Personalitza el codi per incrustar" + header: "Mostrar la capçalera" + autoload: "Carregar automàticament (no recomanat)" + maxHeight: "Alçada màxima" + maxHeightDescription: "0 anul·la la configuració màxima. Per evitar que continuï creixent verticalment, especifiqui qualsevol valor." + maxHeightWarn: "El límit màxim d'alçada és nul (0). Si això no és un canvi previst, estableix el màxim d'alçada a un cert valor." + previewIsNotActual: "La visualització és diferent de la que es mostra quan s'implanta." + rounded: "Angle recte" + border: "Afegeix un marc al contenidor" + applyToPreview: "Aplica a la vista prèvia" + generateCode: "Crea el codi per incrustar" + codeGenerated: "Codi generat" + codeGeneratedDescription: "Si us plau, enganxeu el codi generat al lloc web." diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 7db742476292..4a27ed763542 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -471,7 +471,6 @@ uiLanguage: "Jazyk uživatelského rozhraní" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Výchozí" -disableDrawer: "Nepoužívat šuplíkové menu" showNoteActionsOnlyHover: "Zobrazit akce poznámky jenom při naběhnutí myši" noHistory: "Žádná historie" signinHistory: "Historie přihlášení" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 8e44a3bbd4fc..453f6308f67c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -491,7 +491,6 @@ uiLanguage: "Sprache der Benutzeroberfläche" aboutX: "Über {x}" emojiStyle: "Emoji-Stil" native: "Nativ" -disableDrawer: "Keine ausfahrbaren Menüs verwenden" showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" diff --git a/locales/en-US.yml b/locales/en-US.yml index 2cb76fa74652..4576aadbf9bd 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -182,7 +182,7 @@ addAccount: "Add account" reloadAccountsList: "Reload account list" loginFailed: "Failed to sign in" showOnRemote: "View on remote instance" -continueOnRemote: "リモートで続行" +continueOnRemote: "Continue on a remote server" chooseServerOnMisskeyHub: "Choose a server from the Misskey Hub" specifyServerHost: "Specify a server host directly" inputHostName: "Enter the domain" @@ -348,7 +348,7 @@ avatar: "Avatar" banner: "Banner" displayOfSensitiveMedia: "Display of sensitive media" whenServerDisconnected: "When losing connection to the server" -disconnectedFromServer: "Connection to server has been lost" +disconnectedFromServer: "I'm so sleepy..." reload: "Refresh" doNothing: "Ignore" reloadConfirm: "Would you like to refresh the timeline?" @@ -487,7 +487,7 @@ noMessagesYet: "No messages yet" newMessageExists: "There are new messages" onlyOneFileCanBeAttached: "You can only attach one file to a message" signinRequired: "Please register or sign in before continuing" -signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." +signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." invitations: "Invites" invitationCode: "Invitation code" checking: "Checking..." @@ -509,7 +509,6 @@ uiLanguage: "User interface language" aboutX: "About {x}" emojiStyle: "Emoji style" native: "Native" -disableDrawer: "Don't use drawer-style menus" showNoteActionsOnlyHover: "Only show note actions on hover" showReactionsCount: "See the number of reactions in notes" noHistory: "No history available" @@ -657,6 +656,7 @@ tokenRequested: "Grant access to account" pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." notificationType: "Notification type" edit: "Edit" +editConfirm: "Edit note? Some remote servers won't properly display the edited content." emailServer: "Email server" enableEmail: "Enable email distribution" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" @@ -1079,7 +1079,7 @@ enableChartsForRemoteUser: "Generate remote user data charts" enableChartsForFederatedInstances: "Generate remote instance data charts" showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" reactionsDisplaySize: "Reaction display size" -limitWidthOfReaction: "Limits the maximum width of reactions and display them in reduced size." +limitWidthOfReaction: "Limit the maximum width of reactions and display them in reduced size." noteIdOrUrl: "Note ID or URL" video: "Video" videos: "Videos" @@ -1255,7 +1255,7 @@ launchApp: "Launch the app" useNativeUIForVideoAudioPlayer: "Use UI of browser when play video and audio" keepOriginalFilename: "Keep original file name" keepOriginalFilenameDescription: "If you turn off this setting, files names will be replaced with random string automatically when you upload files." -noDescription: "There is not the explanation" +noDescription: "There is no explanation" alwaysConfirmFollow: "Always confirm when following" inquiry: "Contact" tryAgain: "Please try again later" @@ -1263,6 +1263,11 @@ confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media" sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?" createdLists: "Created lists" createdAntennas: "Created antennas" +noteUpdatedAt: "Edited: {date} {time}" +fromX: "From {x}" +genEmbedCode: "Generate embed code" +noteOfThisUser: "Notes by this user" +clipNoteLimitExceeded: "No more notes can be added to this clip." _delivery: status: "Delivery status" stop: "Suspended" @@ -1365,7 +1370,7 @@ _initialTutorial: _exampleNote: cw: "This will surely make you hungry!" note: "Just had a chocolate-glazed donut 🍩😋" - useCases: "This is used when following the server guidelines for necessary notes or for self-restriction of spoiler or sensitive text." + useCases: "This is used when following the server guidelines, for necessary notes, or for self-restriction of spoiler or sensitive text." _howToMakeAttachmentsSensitive: title: "How to Mark Attachments as Sensitive?" description: "For attachments that are required by server guidelines or that should not be left intact, add a \"sensitive\" flag." @@ -1706,6 +1711,7 @@ _role: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" + canEditNote: "Note editing" mentionMax: "Maximum number of mentions in a note" canInvite: "Can create instance invite codes" inviteLimit: "Invite limit" @@ -2316,6 +2322,7 @@ _pages: eyeCatchingImageSet: "Set thumbnail" eyeCatchingImageRemove: "Delete thumbnail" chooseBlock: "Add a block" + enterSectionTitle: "Enter a section title" selectType: "Select a type" contentBlocks: "Content" inputBlocks: "Input" @@ -2438,7 +2445,7 @@ _webhookSettings: mention: "When being mentioned" _systemEvents: abuseReport: "When received a new abuse report" - abuseReportResolved: "When resolved abuse reports" + abuseReportResolved: "When resolved abuse report" userCreated: "When user is created" deleteConfirm: "Are you sure you want to delete the Webhook?" _abuseReport: @@ -2494,11 +2501,15 @@ _moderationLogTypes: unsetUserAvatar: "Unset this user's avatar" unsetUserBanner: "Unset this user's banner" createSystemWebhook: "Create SystemWebhook" - updateSystemWebhook: "Update SystemWebHook" + updateSystemWebhook: "Update SystemWebhook" deleteSystemWebhook: "Delete SystemWebhook" createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" + deleteAccount: "Delete the account" + deletePage: "Delete the page" + deleteFlash: "Delete Play" + deleteGalleryPost: "Delete the gallery post" _fileViewer: title: "File details" type: "File type" @@ -2635,3 +2646,17 @@ _contextMenu: app: "Application" appWithShift: "Application with shift key" native: "Native" +_embedCodeGen: + title: "Customize embed code" + header: "Show header" + autoload: "Automatically load more (deprecated)" + maxHeight: "Max height" + maxHeightDescription: "Setting it to 0 disables the max height setting. Specify some value to prevent the widget from continuing to expand vertically." + maxHeightWarn: "The max height limit is disabled (0). If this was not intended, set the max height to some value." + previewIsNotActual: "The display differs from the actual embedding because it exceeds the range displayed on the preview screen." + rounded: "Make it rounded" + border: "Add a border to the outer frame" + applyToPreview: "Apply to the preview" + generateCode: "Generate embed code" + codeGenerated: "The code has been generated" + codeGeneratedDescription: "Paste the generated code into your website to embed the content." diff --git a/locales/es-ES.yml b/locales/es-ES.yml index ef066a37edd1..66cab3e95750 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -60,6 +60,7 @@ copyFileId: "Copiar ID del archivo" copyFolderId: "Copiar ID de carpeta" copyProfileUrl: "Copiar la URL del perfil" searchUser: "Buscar un usuario" +searchThisUsersNotes: "" reply: "Responder" loadMore: "Ver más" showMore: "Ver más" @@ -108,11 +109,14 @@ enterEmoji: "Ingresar emojis" renote: "Renotar" unrenote: "Quitar renota" renoted: "Renotado" +renotedToX: "{name} usuarios han renotado。" cantRenote: "No se puede renotar este post" cantReRenote: "No se puede renotar una renota" quote: "Citar" inChannelRenote: "Renota sólo del canal" inChannelQuote: "Cita sólo del canal" +renoteToChannel: "Renotar a otro canal" +renoteToOtherChannel: "Renotar a otro canal" pinnedNote: "Nota fijada" pinned: "Fijar al perfil" you: "Tú" @@ -151,6 +155,7 @@ editList: "Editar lista" selectChannel: "Seleccionar canal" selectAntenna: "Seleccionar antena" editAntenna: "Editar antena" +createAntenna: "Crear una antena" selectWidget: "Seleccionar widget" editWidgets: "Editar widgets" editWidgetsExit: "Terminar edición" @@ -177,6 +182,10 @@ addAccount: "Agregar Cuenta" reloadAccountsList: "Recargar lista de cuentas" loginFailed: "Error al iniciar sesión." showOnRemote: "Ver en una instancia remota" +continueOnRemote: "Ver en una instancia remota" +chooseServerOnMisskeyHub: "Elegir un servidor en Misskey Hub" +specifyServerHost: "Especifica una instancia directamente" +inputHostName: "Introduzca el dominio" general: "General" wallpaper: "Fondo de pantalla" setWallpaper: "Establecer fondo de pantalla" @@ -493,7 +502,6 @@ uiLanguage: "Idioma de visualización de la interfaz" aboutX: "Acerca de {x}" emojiStyle: "Estilo de emoji" native: "Nativo" -disableDrawer: "No mostrar los menús en cajones" showNoteActionsOnlyHover: "Mostrar acciones de la nota sólo al pasar el cursor" showReactionsCount: "Mostrar el número de reacciones en las notas" noHistory: "No hay datos en el historial" @@ -1094,6 +1102,8 @@ preservedUsernames: "Nombre de usuario reservado" preservedUsernamesDescription: "La lista de nombres de usuario para reservar tienen que separarse con saltos de línea.\nEstos estarán indisponibles durante la creación de cuentas, pero pueden ser usados para que los administradores puedan crear esas cuentas manualmente. Las cuentas existentes con esos nombres de usuario no se verán afectadas." createNoteFromTheFile: "Componer una nota desde éste archivo" archive: "Archivo" +archived: "Archivado" +unarchive: "Desarchivar" channelArchiveConfirmTitle: "¿Seguro de archivar {name}?" channelArchiveConfirmDescription: "Un canal archivado no aparecerá en la lista de canales ni en los resultados. Las nuevas publicaciones tampoco serán añadidas." thisChannelArchived: "El canal ha sido archivado." @@ -2395,6 +2405,8 @@ _abuseReport: _notificationRecipient: _recipientType: mail: "Correo" + webhook: "Webhook" + keywords: "Palabras Clave" _moderationLogTypes: createRole: "Rol creado" deleteRole: "Rol eliminado" @@ -2498,6 +2510,7 @@ _hemisphere: S: "Hemisferio sur" _reversi: reversi: "Reversi" + rules: "Reglas" won: "{name} ha ganado" total: "Total" _urlPreviewSetting: diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index ee08dfddf1a1..0cf4b65c38fc 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -493,7 +493,6 @@ uiLanguage: "Langue d’affichage de l’interface" aboutX: "À propos de {x}" emojiStyle: "Style des émojis" native: "Natif" -disableDrawer: "Les menus ne s'affichent pas dans le tiroir" showNoteActionsOnlyHover: "Afficher les actions de note uniquement au survol" showReactionsCount: "Afficher le nombre de réactions des notes" noHistory: "Pas d'historique" @@ -1094,6 +1093,8 @@ preservedUsernames: "Noms d'utilisateur·rice réservés" preservedUsernamesDescription: "Énumérez les noms d'utilisateur à réserver, séparés par des nouvelles lignes. Les noms d'utilisateur spécifiés ici ne seront plus utilisables lors de la création d'un compte, sauf la création manuelle par un administrateur. De plus, les comptes existants ne seront pas affectés." createNoteFromTheFile: "Rédiger une note de ce fichier" archive: "Archive" +archived: "Archivé" +unarchive: "Annuler l'archivage" channelArchiveConfirmTitle: "Voulez-vous vraiment archiver {name} ?" channelArchiveConfirmDescription: "Une fois archivé, le canal n'apparaîtra plus dans la liste des canaux ni dans les résultats de recherche, et la publication des nouvelles notes sera impossible." thisChannelArchived: "Ce canal a été archivé." @@ -1224,7 +1225,10 @@ enableHorizontalSwipe: "Glisser pour changer d'onglet" loading: "Chargement en cours" surrender: "Annuler" gameRetry: "Réessayer" +launchApp: "Lancer l'app" +inquiry: "Contact" _delivery: + status: "Statut de la diffusion" stop: "Suspendu·e" _type: none: "Publié" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 24f7482fcaea..55ca9d91ac1c 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -60,6 +60,7 @@ copyFileId: "Salin Berkas" copyFolderId: "Salin Folder" copyProfileUrl: "Salin Alamat Web Profil" searchUser: "Cari pengguna" +searchThisUsersNotes: "Mencari catatan pengguna" reply: "Balas" loadMore: "Selebihnya" showMore: "Selebihnya" @@ -154,6 +155,7 @@ editList: "Sunting daftar" selectChannel: "Pilih kanal" selectAntenna: "Pilih Antena" editAntenna: "Sunting antena" +createAntenna: "Membuat antena." selectWidget: "Pilih gawit" editWidgets: "Sunting gawit" editWidgetsExit: "Selesai" @@ -502,7 +504,6 @@ uiLanguage: "Bahasa antarmuka pengguna" aboutX: "Tentang {x}" emojiStyle: "Gaya emoji" native: "Native" -disableDrawer: "Jangan gunakan menu bergaya laci" showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk" showReactionsCount: "Lihat jumlah reaksi dalam catatan" noHistory: "Tidak ada riwayat" diff --git a/locales/index.d.ts b/locales/index.d.ts index 91d36a14a627..8e7668ddc5e5 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -960,6 +960,14 @@ export interface Locale extends ILocale { * メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。 */ "mediaSilencedInstancesDescription": string; + /** + * 連合を許可するサーバー + */ + "federationAllowedHosts": string; + /** + * 連合を許可するサーバーのホストを改行で区切って設定します。 + */ + "federationAllowedHostsDescription": string; /** * ミュートとブロック */ @@ -1352,6 +1360,10 @@ export interface Locale extends ILocale { * ファイルを追加 */ "addFile": string; + /** + * ファイルを表示 + */ + "showFile": string; /** * ドライブは空です */ @@ -1409,7 +1421,7 @@ export interface Locale extends ILocale { */ "whenServerDisconnected": string; /** - * サーバーから切断されました + * とても眠いです··· */ "disconnectedFromServer": string; /** @@ -2053,9 +2065,21 @@ export interface Locale extends ILocale { */ "native": string; /** - * メニューをドロワーで表示しない + * メニューのスタイル + */ + "menuStyle": string; + /** + * スタイル + */ + "style": string; + /** + * ドロワー */ - "disableDrawer": string; + "drawer": string; + /** + * ポップアップ + */ + "popup": string; /** * ノートのアクションをホバー時のみ表示する */ @@ -2384,6 +2408,14 @@ export interface Locale extends ILocale { * スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。 */ "scratchpadDescription": string; + /** + * UIインスペクター + */ + "uiInspector": string; + /** + * メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。 + */ + "uiInspectorDescription": string; /** * 出力 */ @@ -2644,6 +2676,10 @@ export interface Locale extends ILocale { * 編集 */ "edit": string; + /** + * ノートを修正しますか?連合するサーバーによっては、修正後のノートが正常に表示されない場合があります。 + */ + "editConfirm": string; /** * メールサーバー */ @@ -2829,7 +2865,7 @@ export interface Locale extends ILocale { */ "reportAbuseOf": ParameterizedString<"name">; /** - * 通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。 + * 通報理由の詳細を記入してください。対象のノートやページなどがある場合はそのURLも記入してください。 */ "fillAbuseReportDescription": string; /** @@ -3121,7 +3157,7 @@ export interface Locale extends ILocale { */ "narrow": string; /** - * 設定はページリロード後に反映されます。今すぐリロードしますか? + * 設定はページリロード後に反映されます。 */ "reloadToApplySetting": string; /** @@ -5068,6 +5104,58 @@ export interface Locale extends ILocale { * 作成したアンテナ */ "createdAntennas": string; + /** + * 編集済み: {date} {time} + */ + "noteUpdatedAt": ParameterizedString<"date" | "time">; + /** + * {x}から + */ + "fromX": ParameterizedString<"x">; + /** + * 埋め込みコードを生成 + */ + "genEmbedCode": string; + /** + * このユーザーのノート一覧 + */ + "noteOfThisUser": string; + /** + * これ以上このクリップにノートを追加できません。 + */ + "clipNoteLimitExceeded": string; + /** + * パフォーマンス + */ + "performance": string; + /** + * 変更あり + */ + "modified": string; + /** + * 破棄 + */ + "discard": string; + /** + * {n}件の変更があります + */ + "thereAreNChanges": ParameterizedString<"n">; + /** + * パスキーでログイン + */ + "signinWithPasskey": string; + /** + * 登録されていないパスキーです。 + */ + "unknownWebAuthnKey": string; + /** + * パスキーの検証に失敗しました。 + */ + "passkeyVerificationFailed": string; + /** + * パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。 + */ + "passkeyVerificationSucceededButPasswordlessLoginDisabled": string; "_delivery": { /** * 配信状態 @@ -5559,6 +5647,10 @@ export interface Locale extends ILocale { * 有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。 */ "fanoutTimelineDbFallbackDescription": string; + /** + * 有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。 + */ + "reactionsBufferingDescription": string; /** * 問い合わせ先URL */ @@ -6642,6 +6734,10 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * ノートの編集 + */ + "canEditNote": string; /** * ノート内の最大メンション数 */ @@ -6738,6 +6834,26 @@ export interface Locale extends ILocale { * アイコンデコレーションの最大取付個数 */ "avatarDecorationLimit": string; + /** + * アンテナのインポートを許可 + */ + "canImportAntennas": string; + /** + * ブロックのインポートを許可 + */ + "canImportBlocking": string; + /** + * フォローのインポートを許可 + */ + "canImportFollowing": string; + /** + * ミュートのインポートを許可 + */ + "canImportMuting": string; + /** + * リストのインポートを許可 + */ + "canImportUserLists": string; }; "_condition": { /** @@ -8629,6 +8745,18 @@ export interface Locale extends ILocale { * 最大{max}つまでデコレーションを付けられます。 */ "avatarDecorationMax": ParameterizedString<"max">; + /** + * フォローされた時のメッセージ + */ + "followedMessage": string; + /** + * フォローされた時に相手に表示する短いメッセージを設定できます。 + */ + "followedMessageDescription": string; + /** + * フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。 + */ + "followedMessageDescriptionForLockedAccount": string; }; "_exportOrImport": { /** @@ -8985,6 +9113,10 @@ export interface Locale extends ILocale { * ブロックを追加 */ "chooseBlock": string; + /** + * セクションタイトルを入力 + */ + "enterSectionTitle": string; /** * 種類を選択 */ @@ -9157,6 +9289,10 @@ export interface Locale extends ILocale { * 通知の履歴をリセットする */ "flushNotification": string; + /** + * {x}のエクスポートが完了しました + */ + "exportOfXCompleted": ParameterizedString<"x">; "_types": { /** * すべて @@ -9210,6 +9346,14 @@ export interface Locale extends ILocale { * 実績の獲得 */ "achievementEarned": string; + /** + * エクスポートが完了した + */ + "exportCompleted": string; + /** + * 通知のテスト + */ + "test": string; /** * 連携アプリからの通知 */ @@ -9457,6 +9601,10 @@ export interface Locale extends ILocale { * Webhookを削除しますか? */ "deleteConfirm": string; + /** + * スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。 + */ + "testRemarks": string; }; "_abuseReport": { "_notificationRecipient": { @@ -9679,6 +9827,22 @@ export interface Locale extends ILocale { * 通報の通知先を削除 */ "deleteAbuseReportNotificationRecipient": string; + /** + * アカウントを削除 + */ + "deleteAccount": string; + /** + * ページを削除 + */ + "deletePage": string; + /** + * Playを削除 + */ + "deleteFlash": string; + /** + * ギャラリーの投稿を削除 + */ + "deleteGalleryPost": string; }; "_fileViewer": { /** @@ -10172,6 +10336,60 @@ export interface Locale extends ILocale { */ "native": string; }; + "_embedCodeGen": { + /** + * 埋め込みコードをカスタマイズ + */ + "title": string; + /** + * ヘッダーを表示 + */ + "header": string; + /** + * 自動で続きを読み込む(非推奨) + */ + "autoload": string; + /** + * 高さの最大値 + */ + "maxHeight": string; + /** + * 0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。 + */ + "maxHeightDescription": string; + /** + * 高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。 + */ + "maxHeightWarn": string; + /** + * プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。 + */ + "previewIsNotActual": string; + /** + * 角丸にする + */ + "rounded": string; + /** + * 外枠に枠線をつける + */ + "border": string; + /** + * プレビューに反映 + */ + "applyToPreview": string; + /** + * 埋め込みコードを作成 + */ + "generateCode": string; + /** + * コードが生成されました + */ + "codeGenerated": string; + /** + * 生成されたコードをウェブサイトに貼り付けてご利用ください。 + */ + "codeGeneratedDescription": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/index.js b/locales/index.js index c2738884eb34..24958073b613 100644 --- a/locales/index.js +++ b/locales/index.js @@ -28,6 +28,8 @@ const languages = [ 'kab-KAB', 'kn-IN', 'ko-KR', + 'ko-NK', + 'ko-GS', 'nl-NL', 'no-NO', 'pl-PL', diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 2b4b1e425e84..55b612cac5cd 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -1,6 +1,6 @@ --- _lang_: "Italiano" -headlineMisskey: "Rete collegata tramite note" +headlineMisskey: "Rete collegata tramite Note" introMisskey: "Eccoci! Misskey è un servizio di microblogging decentralizzato, libero e aperto. \n\n📡 Puoi pubblicare «Note» per condividere ciò che sta succedendo o per dire a tutti qualcosa su di te. \n\n👍 Puoi reagire inviando emoji rapidi alle «Note» provenienti da altri profili nel Fediverso.\n\n🚀 Esplora un nuovo mondo insieme a noi!" poweredByMisskeyDescription: "{name} è uno dei servizi (chiamati istanze) che utilizzano la piattaforma open source Misskey." monthAndDay: "{day}/{month}" @@ -60,6 +60,7 @@ copyFileId: "Copia ID del file" copyFolderId: "Copia ID della cartella" copyProfileUrl: "Copia URL del profilo" searchUser: "Cerca profilo" +searchThisUsersNotes: "Cerca le sue Note" reply: "Rispondi" loadMore: "Mostra di più" showMore: "Espandi" @@ -154,6 +155,7 @@ editList: "Modifica Lista" selectChannel: "Seleziona canale" selectAntenna: "Scegli un'antenna" editAntenna: "Modifica Antenna" +createAntenna: "Crea Antenna" selectWidget: "Seleziona il riquadro" editWidgets: "Modifica i riquadri" editWidgetsExit: "Conferma le modifiche" @@ -194,6 +196,7 @@ followConfirm: "Vuoi seguire {name}?" proxyAccount: "Profilo proxy" proxyAccountDescription: "Un profilo proxy funziona come follower per i profili remoti, sotto certe condizioni. Ad esempio, quando un profilo locale ne inserisce uno remoto in una lista (senza seguirlo), se nessun altro segue quel profilo remoto, le attività non possono essere distribuite. Dunque, il profilo proxy le seguirà per tutti." host: "Host" +selectSelf: "Segli me" selectUser: "Seleziona profilo" recipient: "Destinatario" annotation: "Annotazione preventiva" @@ -209,6 +212,7 @@ perDay: "giornaliero" stopActivityDelivery: "Interrompi la distribuzione di attività" blockThisInstance: "Bloccare l'istanza" silenceThisInstance: "Silenziare l'istanza" +mediaSilenceThisInstance: "Silenzia i media dell'istanza" operations: "Operazioni" software: "Software" version: "Versione" @@ -230,6 +234,8 @@ blockedInstances: "Istanze bloccate" blockedInstancesDescription: "Elenca le istanze che vuoi bloccare, una per riga. Esse non potranno più interagire con la tua istanza." silencedInstances: "Istanze silenziate" silencedInstancesDescription: "Elenca i nomi host delle istanze che vuoi silenziare. Tutti i profili nelle istanze silenziate vengono trattati come tali. Possono solo inviare richieste di follow e menzionare soltanto i profili locali che seguono. Le istanze bloccate non sono interessate." +mediaSilencedInstances: "Istanze coi media silenziati" +mediaSilencedInstancesDescription: "Elenca i nomi host delle istanze di cui vuoi silenziare i media, uno per riga. Tutti gli allegati dei profili nelle istanze silenziate per via degli allegati espliciti, verranno impostati come tali, le emoji personalizzate non saranno disponibili. Le istanze bloccate sono escluse." muteAndBlock: "Silenziare e bloccare" mutedUsers: "Profili silenziati" blockedUsers: "Profili bloccati" @@ -328,6 +334,7 @@ renameFolder: "Rinomina cartella" deleteFolder: "Elimina cartella" folder: "Cartella" addFile: "Allega" +showFile: "Visualizza file" emptyDrive: "Il Drive è vuoto" emptyFolder: "La cartella è vuota" unableToDelete: "Eliminazione impossibile" @@ -449,7 +456,7 @@ securityKeyAndPasskey: "Chiave di sicurezza e accesso" securityKey: "Chiave di sicurezza" lastUsed: "Ultima attività" lastUsedAt: "Uso più recente: {t}" -unregister: "Annulla l'iscrizione" +unregister: "Rimuovi autenticazione a due fattori (2FA/MFA)" passwordLessLogin: "Accedi senza password" passwordLessLoginDescription: "Accedi senza password, usando la chiave di sicurezza" resetPassword: "Ripristina la password" @@ -503,7 +510,10 @@ uiLanguage: "Lingua di visualizzazione dell'interfaccia" aboutX: "Informazioni su {x}" emojiStyle: "Stile emoji" native: "Nativo" -disableDrawer: "Non mostrare il menù sul drawer" +menuStyle: "Stile menu" +style: "Stile" +drawer: "Drawer" +popup: "Popup" showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mouse" showReactionsCount: "Visualizza il numero di reazioni su una nota" noHistory: "Nessuna cronologia" @@ -559,7 +569,7 @@ deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" withRepliesByDefaultForNewlyFollowed: "Quando segui nuovi profili, includi le risposte in TL come impostazione predefinita" -newNoteRecived: "Nuove note da leggere" +newNoteRecived: "Nuove Note da leggere" sounds: "Impostazioni suoni" sound: "Suono" listen: "Ascolta" @@ -586,6 +596,8 @@ ascendingOrder: "Aumenta" descendingOrder: "Diminuisce" scratchpad: "ScratchPad" scratchpadDescription: "Lo Scratchpad offre un ambiente per esperimenti di AiScript. È possibile scrivere, eseguire e confermare i risultati dell'interazione del codice con Misskey." +uiInspector: "UI Inspector" +uiInspectorDescription: "Puoi visualizzare un elenco di elementi UI presenti in memoria. I componenti dell'interfaccia utente vengono generati dalle funzioni Ui:C:." output: "Uscita" script: "Script" disablePagesScript: "Disabilita AiScript nelle pagine" @@ -1106,6 +1118,8 @@ preservedUsernames: "Nomi utente riservati" preservedUsernamesDescription: "Elenca, uno per linea, i nomi utente che non possono essere registrati durante la creazione del profilo. La restrizione non si applica agli amministratori. Inoltre, i profili già registrati sono esenti." createNoteFromTheFile: "Crea Nota da questo file" archive: "Archivio" +archived: "Archiviato" +unarchive: "Annulla archiviazione" channelArchiveConfirmTitle: "Vuoi davvero archiviare {name}?" channelArchiveConfirmDescription: "Un canale archiviato non compare nell'elenco canali, nemmeno nei risultati di ricerca. Non può ricevere nemmeno nuove Note." thisChannelArchived: "Questo canale è stato archiviato." @@ -1116,6 +1130,9 @@ preventAiLearning: "Impedisci l'apprendimento della IA" preventAiLearningDescription: "Aggiungendo il campo \"noai\" alla risposta HTML, si indica ai Robot esterni di non usare testi e allegati per addestrare sistemi di Machine Learning (IA predittiva/generativa). Anche se è impossibile sapere se la richiesta venga onorata o semplicemente ignorata." options: "Opzioni del ruolo" specifyUser: "Profilo specifico" +lookupConfirm: "Vuoi davvero richiedere informazioni?" +openTagPageConfirm: "Vuoi davvero aprire la pagina dell'hashtag?" +specifyHost: "Specifica l'host" failedToPreviewUrl: "Anteprima non disponibile" update: "Aggiorna" rolesThatCanBeUsedThisEmojiAsReaction: "Ruoli che possono usare questa emoji come reazione" @@ -1250,6 +1267,20 @@ inquiry: "Contattaci" tryAgain: "Per favore riprova" confirmWhenRevealingSensitiveMedia: "Richiedi conferma prima di mostrare gli allegati espliciti" sensitiveMediaRevealConfirm: "Questo allegato è esplicito, vuoi vederlo?" +createdLists: "Liste create" +createdAntennas: "Antenne create" +fromX: "Da {x}" +genEmbedCode: "Ottieni il codice di incorporamento" +noteOfThisUser: "Elenco di Note di questo profilo" +clipNoteLimitExceeded: "Non è possibile aggiungere ulteriori Note a questa Clip." +performance: "Prestazioni" +modified: "Modificato" +discard: "Scarta" +thereAreNChanges: "Ci sono {n} cambiamenti" +signinWithPasskey: "Accedi con passkey" +unknownWebAuthnKey: "Questa è una passkey sconosciuta." +passkeyVerificationFailed: "La verifica della passkey non è riuscita." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato." _delivery: status: "Stato della consegna" stop: "Sospensione" @@ -1304,7 +1335,7 @@ _initialAccountSetting: skipAreYouSure: "Vuoi davvero saltare la configurazione iniziale?" laterAreYouSure: "Vuoi davvero rimandare la configurazione iniziale?" _initialTutorial: - launchTutorial: "Guarda il tutorial" + launchTutorial: "Inizia il tutorial" title: "Tutorial" wellDone: "Ottimo lavoro!" skipAreYouSure: "Vuoi davvero interrompere il tutorial?" @@ -1314,13 +1345,13 @@ _initialTutorial: _note: title: "Cosa sono le Note?" description: "Gli status su Misskey sono chiamati \"Note\". Le Note sono elencate in ordine cronologico nelle timeline e vengono aggiornate in tempo reale." - reply: "Puoi rispondere alle Note. Puoi anche rispondere alle risposte e continuare i dialoghi come un conversazioni." - renote: "Puoi ri-condividere le Note, facendole rifluire sulla Timeline. Puoi anche aggiungere testo e citare altri profili." - reaction: "Puoi aggiungere una reazione. Nella pagina successiva spiegheremo i dettagli." - menu: "Puoi svolgere varie attività, come visualizzare i dettagli delle Note o copiare i collegamenti." + reply: "Puoi rispondere alle Note, alle altre risposte e dialogare in conversazioni." + renote: "Puoi ri-condividere le Note, ritorneranno sulla Timeline. Aggiungendo del testo, scriverai una Citazione." + reaction: "Puoi aggiungere una reazione. Nella pagina successiva ti spiego come." + menu: "Per altre attività, ad esempio, vedere i dettagli delle Note o copiare i collegamenti." _reaction: title: "Cosa sono le Reazioni?" - description: "Puoi reagire alle Note. Le sensazioni che non si riescono a trasmettere con i \"Mi piace\" si possono esprimere facilmente inviando una reazione." + description: "Reazioni alle Note. Le sensazioni che non si possono descrivere con \"Mi piace\" si esprimono facilmente con le reazioni." letsTryReacting: "Puoi aggiungere una Reazione cliccando il bottone \"+\" (più) della relativa Nota. Prova ad aggiungerne una a questa Nota di esempio!" reactToContinue: "Aggiungere la Reazione ti consentirà di procedere col tutorial." reactNotification: "Quando qualcuno reagisce alle tue Note, ricevi una notifica in tempo reale." @@ -1333,7 +1364,7 @@ _initialTutorial: social: "sia le Note della Timeline Home che quelle della Timeline Locale, insieme!" global: "le Note da pubblicate da tutte le altre istanze federate con la nostra." description2: "Nella parte superiore dello schermo, puoi scegliere una Timeline o l'altra in qualsiasi momento." - description3: "Ci sono anche sequenze temporali di elenchi, sequenze temporali di canali, ecc. Per ulteriori dettagli, consultare il {link}.\nPuoi vedere anche Timeline delle liste di profili (se ne hai create), canali, ecc... Per i dettagli, visita {link}." + description3: "Ci sono anche sequenze temporali di elenchi, sequenze temporali di canali, ecc. Per ulteriori dettagli, consultare la {link}.\nPuoi vedere anche Timeline delle liste di profili (se ne hai create), canali, ecc... Per i dettagli, c'è la {link}." _postNote: title: "La Nota e le sue impostazioni" description1: "Quando scrivi una Nota su Misskey, hai a disposizione varie opzioni. Il modulo di invio è simile a questo." @@ -1384,6 +1415,7 @@ _serverSettings: fanoutTimelineDescription: "Attivando questa funzionalità migliori notevolmente la capacità delle Timeline di collezionare Note, riducendo il carico sul database. Tuttavia, aumenterà l'impiego di memoria RAM per Redis. Disattiva se il tuo server ha poca RAM o la funzionalità è irregolare." fanoutTimelineDbFallback: "Elaborazione dati alternativa" fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline." + reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis." inquiryUrl: "URL di contatto" inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione." _accountMigration: @@ -1717,6 +1749,11 @@ _role: canSearchNotes: "Ricercare nelle Note" canUseTranslator: "Tradurre le Note" avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili" + canImportAntennas: "Può importare Antenne" + canImportBlocking: "Può importare Blocchi" + canImportFollowing: "Può importare Following" + canImportMuting: "Può importare Silenziati" + canImportUserLists: "Può importare liste di Profili" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" @@ -1954,6 +1991,7 @@ _soundSettings: driveFileTypeWarnDescription: "Per favore, scegli un file di tipo audio" driveFileDurationWarn: "La durata dell'audio è troppo lunga" driveFileDurationWarnDescription: "Scegliere un audio lungo potrebbe interferire con l'uso di Misskey. Vuoi continuare lo stesso?" + driveFileError: "Impossibile caricare l'audio. Si prega di modificare le impostazioni" _ago: future: "Futuro" justNow: "Adesso" @@ -2302,6 +2340,7 @@ _pages: eyeCatchingImageSet: "Imposta un'immagine attraente" eyeCatchingImageRemove: "Elimina immagine attraente" chooseBlock: "Aggiungi blocco" + enterSectionTitle: "Inserisci il titolo della sezione" selectType: "Seleziona tipo" contentBlocks: "Contenuto" inputBlocks: "Blocchi di input" @@ -2347,6 +2386,7 @@ _notification: renotedBySomeUsers: "{n} Rinota" followedBySomeUsers: "{n} follower" flushNotification: "Azzera le notifiche" + exportOfXCompleted: "Abbiamo completato l'esportazione di {x}" _types: all: "Tutto" note: "Nuove Note" @@ -2361,6 +2401,8 @@ _notification: followRequestAccepted: "Richiesta di follow accettata" roleAssigned: "Ruolo concesso" achievementEarned: "Risultato raggiunto" + exportCompleted: "Esportazione completata" + test: "Prova la notifica" app: "Notifiche da applicazioni" _actions: followBack: "Segui" @@ -2412,6 +2454,7 @@ _webhookSettings: modifyWebhook: "Modifica Webhook" name: "Nome" secret: "Segreto" + trigger: "Trigger" active: "Attivo" _events: follow: "Quando segui un profilo" @@ -2424,7 +2467,9 @@ _webhookSettings: _systemEvents: abuseReport: "Quando arriva una segnalazione" abuseReportResolved: "Quando una segnalazione è risolta" + userCreated: "Quando viene creato un profilo" deleteConfirm: "Vuoi davvero eliminare il Webhook?" + testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi." _abuseReport: _notificationRecipient: createRecipient: "Aggiungi destinatario della segnalazione" @@ -2483,6 +2528,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "Crea destinatario per le notifiche di segnalazioni" updateAbuseReportNotificationRecipient: "Aggiorna destinatario notifiche di segnalazioni" deleteAbuseReportNotificationRecipient: "Elimina destinatario notifiche di segnalazioni" + deleteAccount: "Quando viene eliminato un profilo" + deletePage: "Pagina eliminata" + deleteFlash: "Play eliminato" + deleteGalleryPost: "Eliminazione pubblicazione nella Galleria" _fileViewer: title: "Dettagli del file" type: "Tipo di file" @@ -2614,3 +2663,22 @@ _mediaControls: pip: "Sovraimpressione" playbackRate: "Velocità di riproduzione" loop: "Ripetizione infinita" +_contextMenu: + title: "Menu contestuale" + app: "Applicazione" + appWithShift: "Applicazione Shift+Tasto" + native: "Interfaccia utente del browser" +_embedCodeGen: + title: "Personalizza il codice di incorporamento" + header: "Mostra la testata" + autoload: "Carica automaticamente di più (sconsigliato)" + maxHeight: "Altezza massima" + maxHeightDescription: "Specifica un valore per evitare che continui a crescere verticalmente. Il valore 0 disabilita il limite d'altezza." + maxHeightWarn: "L'altezza massima è disabilitata (0). Se l'effetto è indesiderato, prova a impostare l'altezza massima a un valore specifico." + previewIsNotActual: "Poiché supera l'intervallo che può essere visualizzato in anteprima, la visualizzazione vera e propria sarà diversa quando effettivamente incorporata." + rounded: "Bordo arrotondato" + border: "Aggiungi un bordo al contenitore" + applyToPreview: "Applica all'anteprima" + generateCode: "Crea il codice di incorporamento" + codeGenerated: "Codice generato" + codeGeneratedDescription: "Incolla il codice appena generato sul tuo sito web." diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b493183974cc..ebd8ad64f490 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -236,6 +236,8 @@ silencedInstances: "サイレンスしたサーバー" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。" mediaSilencedInstances: "メディアサイレンスしたサーバー" mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。" +federationAllowedHosts: "連合を許可するサーバー" +federationAllowedHostsDescription: "連合を許可するサーバーのホストを改行で区切って設定します。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -334,6 +336,7 @@ renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" folder: "フォルダー" addFile: "ファイルを追加" +showFile: "ファイルを表示" emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" unableToDelete: "削除できません" @@ -348,7 +351,7 @@ avatar: "アイコン" banner: "バナー" displayOfSensitiveMedia: "センシティブなメディアの表示" whenServerDisconnected: "サーバーとの接続が失われたとき" -disconnectedFromServer: "サーバーから切断されました" +disconnectedFromServer: "とても眠いです···" reload: "リロード" doNothing: "なにもしない" reloadConfirm: "リロードしますか?" @@ -509,7 +512,10 @@ uiLanguage: "UIの表示言語" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示しない" +menuStyle: "メニューのスタイル" +style: "スタイル" +drawer: "ドロワー" +popup: "ポップアップ" showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" showReactionsCount: "ノートのリアクション数を表示する" noHistory: "履歴はありません" @@ -592,6 +598,8 @@ ascendingOrder: "昇順" descendingOrder: "降順" scratchpad: "スクラッチパッド" scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。" +uiInspector: "UIインスペクター" +uiInspectorDescription: "メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。" output: "出力" script: "スクリプト" disablePagesScript: "Pagesのスクリプトを無効にする" @@ -657,6 +665,7 @@ tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" notificationType: "通知の種類" edit: "編集" +editConfirm: "ノートを修正しますか?連合するサーバーによっては、修正後のノートが正常に表示されない場合があります。" emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" @@ -703,7 +712,7 @@ abuseReports: "通報" reportAbuse: "通報" reportAbuseRenote: "リノートを通報" reportAbuseOf: "{name}を通報する" -fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" +fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートやページなどがある場合はそのURLも記入してください。" abuseReported: "内容が送信されました。ご報告ありがとうございました。" reporter: "通報者" reporteeOrigin: "通報先" @@ -776,7 +785,7 @@ left: "左" center: "中央" wide: "広い" narrow: "狭い" -reloadToApplySetting: "設定はページリロード後に反映されます。今すぐリロードしますか?" +reloadToApplySetting: "設定はページリロード後に反映されます。" needReloadToApply: "反映には再起動が必要です。" showTitlebar: "タイトルバーを表示する" clearCache: "キャッシュをクリア" @@ -1263,6 +1272,19 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示 sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" createdLists: "作成したリスト" createdAntennas: "作成したアンテナ" +noteUpdatedAt: "編集済み: {date} {time}" +fromX: "{x}から" +genEmbedCode: "埋め込みコードを生成" +noteOfThisUser: "このユーザーのノート一覧" +clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" +performance: "パフォーマンス" +modified: "変更あり" +discard: "破棄" +thereAreNChanges: "{n}件の変更があります" +signinWithPasskey: "パスキーでログイン" +unknownWebAuthnKey: "登録されていないパスキーです。" +passkeyVerificationFailed: "パスキーの検証に失敗しました。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" _delivery: status: "配信状態" @@ -1405,6 +1427,7 @@ _serverSettings: fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" fanoutTimelineDbFallback: "データベースへのフォールバック" fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" + reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" @@ -1717,6 +1740,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canEditNote: "ノートの編集" mentionMax: "ノート内の最大メンション数" canInvite: "サーバー招待コードの発行" inviteLimit: "招待コードの作成可能数" @@ -1741,6 +1765,11 @@ _role: canSearchNotes: "ノート検索の利用" canUseTranslator: "翻訳機能の利用" avatarDecorationLimit: "アイコンデコレーションの最大取付個数" + canImportAntennas: "アンテナのインポートを許可" + canImportBlocking: "ブロックのインポートを許可" + canImportFollowing: "フォローのインポートを許可" + canImportMuting: "ミュートのインポートを許可" + canImportUserLists: "リストのインポートを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -2273,6 +2302,9 @@ _profile: changeBanner: "バナー画像を変更" verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。" avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。" + followedMessage: "フォローされた時のメッセージ" + followedMessageDescription: "フォローされた時に相手に表示する短いメッセージを設定できます。" + followedMessageDescriptionForLockedAccount: "フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。" _exportOrImport: allNotes: "全てのノート" @@ -2371,6 +2403,7 @@ _pages: eyeCatchingImageSet: "アイキャッチ画像を設定" eyeCatchingImageRemove: "アイキャッチ画像を削除" chooseBlock: "ブロックを追加" + enterSectionTitle: "セクションタイトルを入力" selectType: "種類を選択" contentBlocks: "コンテンツ" inputBlocks: "入力" @@ -2419,6 +2452,7 @@ _notification: renotedBySomeUsers: "{n}人がリノートしました" followedBySomeUsers: "{n}人にフォローされました" flushNotification: "通知の履歴をリセットする" + exportOfXCompleted: "{x}のエクスポートが完了しました" _types: all: "すべて" @@ -2434,6 +2468,8 @@ _notification: followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + exportCompleted: "エクスポートが完了した" + test: "通知のテスト" app: "連携アプリからの通知" _actions: @@ -2507,6 +2543,7 @@ _webhookSettings: abuseReportResolved: "ユーザーからの通報を処理したとき" userCreated: "ユーザーが作成されたとき" deleteConfirm: "Webhookを削除しますか?" + testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" _abuseReport: _notificationRecipient: @@ -2567,6 +2604,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "通報の通知先を作成" updateAbuseReportNotificationRecipient: "通報の通知先を更新" deleteAbuseReportNotificationRecipient: "通報の通知先を削除" + deleteAccount: "アカウントを削除" + deletePage: "ページを削除" + deleteFlash: "Playを削除" + deleteGalleryPost: "ギャラリーの投稿を削除" _fileViewer: title: "ファイルの詳細" @@ -2712,3 +2753,18 @@ _contextMenu: app: "アプリケーション" appWithShift: "Shiftキーでアプリケーション" native: "ブラウザのUI" + +_embedCodeGen: + title: "埋め込みコードをカスタマイズ" + header: "ヘッダーを表示" + autoload: "自動で続きを読み込む(非推奨)" + maxHeight: "高さの最大値" + maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。" + maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。" + previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。" + rounded: "角丸にする" + border: "外枠に枠線をつける" + applyToPreview: "プレビューに反映" + generateCode: "埋め込みコードを作成" + codeGenerated: "コードが生成されました" + codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 5969082cf2a6..660fa38e386c 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -60,6 +60,7 @@ copyFileId: "ファイルIDをコピー" copyFolderId: "フォルダーIDをコピー" copyProfileUrl: "プロフィールURLをコピー" searchUser: "ユーザーを探す" +searchThisUsersNotes: "ユーザーのノートを検索" reply: "返事" loadMore: "まだまだあるで!" showMore: "まだまだあるで!" @@ -114,6 +115,8 @@ cantReRenote: "リノート自体はリノートできへんで。" quote: "引用" inChannelRenote: "チャンネルの中でリノート" inChannelQuote: "チャンネル内引用" +renoteToChannel: "チャンネルにリノート" +renoteToOtherChannel: "他のチャンネルにリノート" pinnedNote: "ピン留めされとるノート" pinned: "ピン留めしとく" you: "あんた" @@ -152,6 +155,7 @@ editList: "リストいじる" selectChannel: "チャンネルを選ぶ" selectAntenna: "アンテナを選ぶ" editAntenna: "アンテナいじる" +createAntenna: "アンテナを作成" selectWidget: "ウィジェットを選ぶ" editWidgets: "ウィジェットをいじる" editWidgetsExit: "いじるのをやめる" @@ -178,6 +182,10 @@ addAccount: "アカウントを追加" reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗してもうた…" showOnRemote: "リモートで見る" +continueOnRemote: "リモートで続行" +chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択" +specifyServerHost: "サーバーのドメインを直接指定" +inputHostName: "ドメインを入力せえや" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" @@ -188,6 +196,7 @@ followConfirm: "{name}をフォローしてええか?" proxyAccount: "プロキシアカウント" proxyAccountDescription: "プロキシアカウントは、代わりにフォローしてくれるアカウントや。例えば、551に豚まんが無いときやったり、ユーザーがリモートユーザーをアカウントに入れたとき、リストに入れられたユーザーが誰からもフォローされてないと寂しいやん。寂しいし、アクティビティも配達されへんから、プロキシアカウントがフォローしてくれるで。ええやつやん…" host: "ホスト" +selectSelf: "自分を選択" selectUser: "ユーザーを選ぶ" recipient: "宛先" annotation: "注釈" @@ -203,6 +212,7 @@ perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送をやめる" blockThisInstance: "このサーバーをブロックすんで" silenceThisInstance: "サーバーサイレンスすんで?" +mediaSilenceThisInstance: "サーバーをメディアサイレンス" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -224,6 +234,8 @@ blockedInstances: "ブロックしたサーバー" blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定してな。ブロックされてもうたサーバーとはもう金輪際やり取りできひんくなるで。" silencedInstances: "サーバーサイレンスされてんねん" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定すんで。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになり、フォロワーでないローカルアカウントにはメンションできなくなんねん。ブロックしたインスタンスには影響せーへんで。" +mediaSilencedInstances: "メディアサイレンスしたサーバー" +mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定するで。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われてな、カスタム絵文字が使えへんようになるで。ブロックしたインスタンスには影響せえへんで。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしとるユーザー" blockedUsers: "ブロックしとるユーザー" @@ -475,6 +487,7 @@ noMessagesYet: "まだチャットはあらへんで" newMessageExists: "新しいメッセージがきたで" onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" +signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があるで" invitations: "来てや" invitationCode: "招待コード" checking: "確認しとるで" @@ -496,7 +509,6 @@ uiLanguage: "UIの表示言語" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示せえへん" showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" showReactionsCount: "ノートのリアクション数を表示する" noHistory: "履歴はないわ。" @@ -1025,6 +1037,7 @@ thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめとく" thisPostMayBeAnnoyingIgnore: "このまま投稿" collapseRenotes: "見たことあるリノートは飛ばして表示するで" +collapseRenotesDescription: "リアクションやリノートをしたことがあるノートをたたんで表示するで。" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバーでなんか変なこと起こっとるわ。" copyErrorInfo: "エラー情報をコピるで" @@ -1098,6 +1111,8 @@ preservedUsernames: "予約ユーザー名" preservedUsernamesDescription: "予約しとくユーザー名を行ごとに挙げるで。ここで指定されたユーザー名はアカウント作るときに使えへんくなるけど、管理者は例外や。あと、もうあるアカウントも例外やな。" createNoteFromTheFile: "このファイル使うてノート作るで" archive: "アーカイブ" +archived: "アーカイブ済み" +unarchive: "アーカイブ解除" channelArchiveConfirmTitle: "{name}をアーカイブしてええか?" channelArchiveConfirmDescription: "アーカイブしたら、チャンネル一覧とか検索結果からなくなるし、新しく書き込みもできへんなるで。" thisChannelArchived: "このチャンネル、アーカイブされとるで。" @@ -1108,6 +1123,9 @@ preventAiLearning: "生成AIの学習に使わんといて" preventAiLearningDescription: "他の文章生成AIとか画像生成AIに、投稿したノートとか画像なんかを勝手に使わんように頼むで。具体的にはnoaiフラグをHTMLレスポンスに含めるんやけど、これ聞いてくれるんはAIの気分次第やから、使われる可能性もちょっとはあるな。" options: "オプション" specifyUser: "ユーザー指定" +lookupConfirm: "照会するけどええか?" +openTagPageConfirm: "ハッシュタグのページを開くんか?" +specifyHost: "ホスト指定" failedToPreviewUrl: "プレビューできへん" update: "更新" rolesThatCanBeUsedThisEmojiAsReaction: "ツッコミとして使えるロール" @@ -1239,10 +1257,20 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ noDescription: "説明文はあらへんで" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "問い合わせ" +tryAgain: "もう一度試しいや。" +confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する" +sensitiveMediaRevealConfirm: "センシティブなメディアやで。表示するんか?" +createdLists: "作成したリスト" +createdAntennas: "作成したアンテナ" _delivery: + status: "配信状態" stop: "配信せぇへん" + resume: "配信再開" _type: none: "配信しとる" + manuallySuspended: "手動停止中" + goneSuspended: "サーバー削除のため停止中" + autoSuspendedForNotResponding: "サーバー応答せえへんから停止中" _bubbleGame: howToPlay: "遊び方" hold: "ホールド" @@ -1368,6 +1396,8 @@ _serverSettings: fanoutTimelineDescription: "入れると、おのおのタイムラインを取得するときにめちゃめちゃ動きが良うなって、データベースが軽くなるわ。でも、Redisのメモリ使う量が増えるから注意な。サーバーのメモリが足りんときとか、動きが変なときは切れるで。" fanoutTimelineDbFallback: "データベースにフォールバックする" fanoutTimelineDbFallbackDescription: "有効にしたら、タイムラインがキャッシュん中に入ってないときにDBにもっかい問い合わせるフォールバック処理ってのをやっとくで。切ったらフォールバック処理をやらんからサーバーはもっと軽くなんねんけど、タイムラインの取得範囲がちょっと減るで。" + inquiryUrl: "問い合わせ先URL" + inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定するで。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに引っ越す" moveFromSub: "別のアカウントへエイリアスを作る" @@ -1684,6 +1714,7 @@ _role: canManageAvatarDecorations: "アバターを飾るモンの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "勝手にファイルにNSFWをくっつける" + canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートピン留めできる数" antennaMax: "アンテナ作れる数" wordMuteMax: "ワードミュートの最大文字数" @@ -1935,6 +1966,7 @@ _soundSettings: driveFileTypeWarnDescription: "音声ファイルを選びや" driveFileDurationWarn: "音が長すぎるわ" driveFileDurationWarnDescription: "長い音使うたらMisskey使うのに良うないかもしれへんで。それでもええか?" + driveFileError: "音声が読み込めへんかったで。設定を変更せえや" _ago: future: "未来" justNow: "ついさっき" @@ -2351,6 +2383,7 @@ _deck: alwaysShowMainColumn: "いつもメインカラムを表示" columnAlign: "カラムの寄せ" addColumn: "カラムを追加" + newNoteNotificationSettings: "新着ノート通知の設定" configureColumn: "カラムの設定" swapLeft: "左に移動" swapRight: "右に移動" @@ -2389,8 +2422,10 @@ _drivecleaner: orderByCreatedAtAsc: "追加日の古い順" _webhookSettings: createWebhook: "Webhookをつくる" + modifyWebhook: "Webhookを編集" name: "名前" secret: "シークレット" + trigger: "トリガー" active: "有効" _events: follow: "フォローしたとき~!" @@ -2400,11 +2435,25 @@ _webhookSettings: renote: "リノートされるとき~!" reaction: "ツッコまれたとき~!" mention: "メンションがあるとき~!" + _systemEvents: + abuseReport: "ユーザーから通報があったとき" + abuseReportResolved: "ユーザーからの通報を処理したとき" + userCreated: "ユーザーが作成されたとき" deleteConfirm: "ほんまにWebhookをほかしてもええんか?" _abuseReport: _notificationRecipient: + createRecipient: "通報の通知先を追加" + modifyRecipient: "通報の通知先を編集" + recipientType: "通知先の種類" _recipientType: mail: "メール" + webhook: "Webhook" + _captions: + mail: "モデレーター権限を持つユーザーのメアドに通知を送るで(通報を受けた時のみ)" + webhook: "指定したSystemWebhookに通知を送るで(通報を受けた時と通報を解決した時にそれぞれ発信)" + keywords: "キーワード" + notifiedUser: "通知先ユーザー" + notifiedWebhook: "使用するWebhook" deleteConfirm: "通知先を削除してもええか?" _moderationLogTypes: createRole: "ロールを追加すんで" @@ -2443,6 +2492,8 @@ _moderationLogTypes: deleteAvatarDecoration: "アイコンデコレーションを削除" unsetUserAvatar: "この子のアイコン元に戻す" unsetUserBanner: "この子のバナー元に戻す" + createSystemWebhook: "SystemWebhookを作成" + updateSystemWebhook: "SystemWebhookを更新" _fileViewer: title: "ファイルの詳しい情報" type: "ファイルの種類" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 9323ed2a26ae..b294bb31cd1b 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -328,7 +328,7 @@ avatar: "아바타" banner: "배너" displayOfSensitiveMedia: "수ᇚ힌 옝상물 보기" whenServerDisconnected: "서버하고 옌겔이 껂기모" -disconnectedFromServer: "서버하고 옌겔이 껂깃십니다" +disconnectedFromServer: "너무 졸려요..." reload: "새로곤침" doNothing: "무시하기" reloadConfirm: "새로곤침합니꺼?" @@ -476,7 +476,6 @@ uiLanguage: "UI 표시 언어" aboutX: "{x}에 대해서" emojiStyle: "이모지 모양" native: "기본" -disableDrawer: "드로어 메뉴 쓰지 않기" showNoteActionsOnlyHover: "마우스 올맀을 때만 노트 액션 버턴 보이기" noHistory: "기록이 없십니다" signinHistory: "로그인 기록" @@ -583,6 +582,9 @@ describeFile: "캡션 옇기" enterFileDescription: "캡션 서기" author: "맨던 사람" manage: "간리" +large: "커게" +medium: "엔갆게" +small: "쪼맪게" emailServer: "전자우펜 서버" email: "전자우펜" emailAddress: "전자우펜 주소" @@ -600,6 +602,7 @@ reporter: "신고한 사람" reporteeOrigin: "신고덴 사람" reporterOrigin: "신고한 곳" forwardReport: "웬겍 서버에 신고 보내기" +forwardReportIsAnonymous: "웬겍 서버서는 나으 정보럴 몬 보고 익멩으 시스템 게정어로 보입니다." waitingFor: "{x}(얼)럴 지달리고 잇십니다" random: "무작이" system: "시스템" @@ -613,12 +616,14 @@ followersCount: "팔로워 수" noteFavoritesCount: "질겨찾기한 노트 수" clips: "클립 맨걸기" clearCache: "캐시 비우기" +nUsers: "{n} 사용자" typingUsers: "{users} 님이 서고 잇어예" unlikeConfirm: "좋네예럴 무룹니꺼?" info: "정보" selectAccount: "계정 개리기" user: "사용자" administration: "간리" +middle: "엔갆게" translatedFrom: "{x}서 번옉" on: "킴" off: "껌" @@ -633,6 +638,7 @@ oneMonth: "한 달" file: "파일" typeToConfirm: "게속할라먼 {x}럴 누질라 주이소" pleaseSelect: "개리 주이소" +remoteOnly: "웬겍만" tools: "도구" like: "좋네예!" unlike: "좋네예 무루기" @@ -643,7 +649,10 @@ role: "옉할" noRole: "옉할이 어ᇝ십니다" thisPostMayBeAnnoyingCancel: "아이예" likeOnly: "좋네예마" +hiddenTags: "수ᇚ훈 해시태그" myClips: "내 클립" +preservedUsernames: "예약 사용자 이럼" +specifyUser: "사용자 지정" icon: "아바타" replies: "답하기" renotes: "리노트" @@ -709,6 +718,16 @@ _achievements: description: "0분 0초에 노트를 섰어예" _tutorialCompleted: description: "길라잡이럴 껕냇십니다" +_role: + displayOrder: "보기 순서" + _priority: + middle: "엔갆게" + _options: + canHideAds: "강고 수ᇚ후기" + _condition: + isRemote: "웬겍 사용자" + isCat: "갱이 사용자" + isBot: "자동 사용자" _gallery: my: "내 걸" liked: "좋네예한 걸" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 34c1cc3ebfb5..2229409e2d58 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -52,14 +52,15 @@ deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집하시겠습니 addToList: "리스트에 추가" addToAntenna: "안테나에 추가" sendMessage: "메시지 보내기" -copyRSS: "RSS 주소 복사" +copyRSS: "RSS 복사" copyUsername: "유저명 복사" copyUserId: "유저 ID 복사" copyNoteId: "노트 ID 복사" copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" -searchUser: "유저 검색" +searchUser: "사용자 검색" +searchThisUsersNotes: "사용자의 노트 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -154,6 +155,7 @@ editList: "리스트 편집" selectChannel: "채널 선택" selectAntenna: "안테나 선택" editAntenna: "안테나 편집" +createAntenna: "안테나 만들기" selectWidget: "위젯 선택" editWidgets: "위젯 편집" editWidgetsExit: "편집 종료" @@ -194,6 +196,7 @@ followConfirm: "{name}님을 팔로우 하시겠습니까?" proxyAccount: "프록시 계정" proxyAccountDescription: "프록시 계정은 특정 조건 하에서 유저의 리모트 팔로우를 대행하는 계정입니다. 예를 들면, 유저가 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에, 대신 프록시 계정이 해당 유저를 팔로우하도록 합니다." host: "호스트" +selectSelf: "본인을 선택" selectUser: "유저 선택" recipient: "수신인" annotation: "내용에 대한 주석" @@ -209,6 +212,7 @@ perDay: "1일마다" stopActivityDelivery: "액티비티 보내지 않기" blockThisInstance: "이 서버를 차단" silenceThisInstance: "서버를 사일런스" +mediaSilenceThisInstance: "서버의 미디어를 사일런스" operations: "작업" software: "소프트웨어" version: "버전" @@ -230,6 +234,10 @@ blockedInstances: "차단된 서버" blockedInstancesDescription: "차단하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정합니다. 차단된 인스턴스는 이 인스턴스와 통신할 수 없게 됩니다." silencedInstances: "사일런스한 서버" silencedInstancesDescription: "사일런스하려는 서버의 호스트명을 한 줄에 하나씩 입력합니다. 사일런스된 서버에 소속된 유저는 모두 '사일런스'된 상태로 취급되며, 이 서버로부터의 팔로우가 프로필 설정과 무관하게 승인제로 변경되고, 팔로워가 아닌 로컬 유저에게는 멘션할 수 없게 됩니다. 정지된 서버에는 적용되지 않습니다." +mediaSilencedInstances: "미디어를 사일런스한 서버" +mediaSilencedInstancesDescription: "미디어를 사일런스 하려는 서버의 호스트를 한 줄에 하나씩 입력합니다. 미디어가 사일런스된 서버의 유저가 업로드한 파일은 모두 민감한 미디어로 처리되며, 커스텀 이모지를 사용할 수 없게 됩니다. 또한, 차단한 인스턴스에는 적용되지 않습니다." +federationAllowedHosts: "연합을 허가하는 서버" +federationAllowedHostsDescription: "연합을 허가하는 서버의 호스트를 엔터로 구분해서 설정합니다." muteAndBlock: "뮤트 및 차단" mutedUsers: "뮤트한 유저" blockedUsers: "차단한 유저" @@ -328,6 +336,7 @@ renameFolder: "폴더 이름 바꾸기" deleteFolder: "폴더 삭제" folder: "폴더" addFile: "파일 추가" +showFile: "파일 표시하기" emptyDrive: "드라이브가 비어 있습니다" emptyFolder: "폴더가 비어 있습니다" unableToDelete: "삭제할 수 없습니다" @@ -342,7 +351,7 @@ avatar: "아바타" banner: "배너" displayOfSensitiveMedia: "민감한 미디어 표시" whenServerDisconnected: "서버와의 접속이 끊겼을 때" -disconnectedFromServer: "서버와의 연결이 끊어졌습니다" +disconnectedFromServer: "너무 졸려요..." reload: "새로고침" doNothing: "무시하기" reloadConfirm: "새로고침 하시겠습니까?" @@ -373,7 +382,7 @@ registration: "등록" enableRegistration: "신규 회원가입을 활성화" invite: "초대" driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" -driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량" +driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량" inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" @@ -503,7 +512,10 @@ uiLanguage: "UI 표시 언어" aboutX: "{x}에 대하여" emojiStyle: "이모지 스타일" native: "기본" -disableDrawer: "드로어 메뉴를 사용하지 않기" +menuStyle: "메뉴 스타일" +style: "스타일" +drawer: "서랍" +popup: "팝업" showNoteActionsOnlyHover: "마우스가 올라간 때에만 노트 동작 버튼을 표시하기" showReactionsCount: "노트의 반응 수를 표시하기" noHistory: "기록이 없습니다" @@ -586,6 +598,8 @@ ascendingOrder: "오름차순" descendingOrder: "내림차순" scratchpad: "스크래치 패드" scratchpadDescription: "스크래치 패드는 AiScript 의 테스트 환경을 제공합니다. Misskey 와 상호 작용하는 코드를 작성, 실행 및 결과를 확인할 수 있습니다." +uiInspector: "UI 인스펙터" +uiInspectorDescription: "메모리에 있는 UI 컴포넌트의 인스턴트 목록을 볼 수 있습니다. UI 컴포넌트는 Ui:C: 계열 함수로 만들어집니다." output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" @@ -651,6 +665,7 @@ tokenRequested: "계정 접근 허용" pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." notificationType: "알림 유형" edit: "편집" +editConfirm: "노트를 수정하시겠습니까? 연합되는 서버에 따라 수정 후의 노트가 정상적으로 표시되지 않을 수 있습니다." emailServer: "메일 서버" enableEmail: "이메일 송신 기능 활성화" emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." @@ -1106,6 +1121,8 @@ preservedUsernames: "예약한 사용자 이름" preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." createNoteFromTheFile: "이 파일로 노트를 작성" archive: "아카이브" +archived: "보관됨" +unarchive: "보관 취소" channelArchiveConfirmTitle: "{name} 채널을 보존하시겠습니까?" channelArchiveConfirmDescription: "보존한 채널은 채널 목록과 검색 결과에 표시되지 않으며 새로운 노트도 작성할 수 없습니다." thisChannelArchived: "이 채널은 보존되었습니다." @@ -1116,6 +1133,9 @@ preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다." options: "옵션" specifyUser: "사용자 지정" +lookupConfirm: "조회 할까요?" +openTagPageConfirm: "해시태그의 페이지를 열까요?" +specifyHost: "호스트 지정" failedToPreviewUrl: "미리 볼 수 없음" update: "업데이트" rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할" @@ -1249,6 +1269,22 @@ alwaysConfirmFollow: "팔로우일 때 항상 확인하기" inquiry: "문의하기" tryAgain: "다시 시도해 주세요." confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인" +noteUpdatedAt: "편집됨: {date} {time}" +sensitiveMediaRevealConfirm: "민감한 미디어입니다. 표시할까요?" +createdLists: "만든 리스트" +createdAntennas: "만든 안테나" +fromX: "{x}부터" +genEmbedCode: "임베디드 코드 만들기" +noteOfThisUser: "이 유저의 노트 목록" +clipNoteLimitExceeded: "더 이상 이 클립에 노트를 추가 할 수 없습니다." +performance: "퍼포먼스" +modified: "변경 있음" +discard: "파기" +thereAreNChanges: "{n}건 변경이 있습니다." +signinWithPasskey: "패스키로 로그인" +unknownWebAuthnKey: "등록되지 않은 패스키입니다." +passkeyVerificationFailed: "패스키 검증을 실패했습니다." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다." _delivery: status: "전송 상태" stop: "정지됨" @@ -1383,6 +1419,7 @@ _serverSettings: fanoutTimelineDescription: "활성화하면 각종 타임라인을 가져올 때의 성능을 대폭 향상하며, 데이터베이스의 부하를 줄일 수 있습니다. 단, Redis의 메모리 사용량이 증가합니다. 서버의 메모리 용량이 작거나, 서비스가 불안정해지는 경우 비활성화할 수 있습니다." fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기" fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져옵니다. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정됩니다." + reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다." inquiryUrl: "문의처 URL" inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다." _accountMigration: @@ -1692,6 +1729,7 @@ _role: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" + canEditNote: "노트 편집 허용" mentionMax: "노트에 넣을 수 있는 멘션 수" canInvite: "서버 초대 코드 발행" inviteLimit: "초대 한도" @@ -1716,10 +1754,15 @@ _role: canSearchNotes: "노트 검색 이용 가능 여부" canUseTranslator: "번역 기능의 사용" avatarDecorationLimit: "아바타 장식의 최대 붙임 개수" + canImportAntennas: "안테나 가져오기 허용" + canImportBlocking: "차단 목록 가져오기 허용" + canImportFollowing: "팔로우 가져오기 허용" + canImportMuting: "뮤트 목록 가져오기 허용" + canImportUserLists: "리스트 목록 가져오기 허용" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" isLocal: "로컬 사용자" - isRemote: "리모트 사용자" + isRemote: "원격 사용자" isCat: "고양이 사용자" isBot: "봇 사용자" isSuspended: "정지된 사용자" @@ -1953,6 +1996,7 @@ _soundSettings: driveFileTypeWarnDescription: "오디오 파일을 선택하세요." driveFileDurationWarn: "오디오가 너무 깁니다" driveFileDurationWarnDescription: "긴 오디오로 설정할 경우 미스키 사용에 지장이 갈 수도 있습니다. 그래도 괜찮습니까?" + driveFileError: "오디오를 불러올 수 없습니다. 설정을 바꿔주세요." _ago: future: "미래" justNow: "방금 전" @@ -2209,6 +2253,9 @@ _profile: changeBanner: "배너 이미지 변경" verifiedLinkDescription: "내용에 자신의 프로필로 향하는 링크가 포함된 페이지의 URL을 삽입하면 소유자 인증 마크가 표시됩니다." avatarDecorationMax: "최대 {max}개까지 장식을 할 수 있습니다." + followedMessage: "팔로우 받았을 때 메시지" + followedMessageDescription: "팔로우 받았을 때 상대방에게 보여줄 단문 메시지를 설정할 수 있습니다." + followedMessageDescriptionForLockedAccount: "팔로우를 승인제로 한 경우, 팔로우 요청을 수락했을 때 보여줍니다." _exportOrImport: allNotes: "모든 노트" favoritedNotes: "즐겨찾기한 노트" @@ -2301,6 +2348,7 @@ _pages: eyeCatchingImageSet: "아이캐치 이미지를 설정" eyeCatchingImageRemove: "아이캐치 이미지를 삭제" chooseBlock: "블록 추가" + enterSectionTitle: "섹션 타이틀을 입력하기" selectType: "종류 선택" contentBlocks: "콘텐츠" inputBlocks: "입력" @@ -2346,6 +2394,7 @@ _notification: renotedBySomeUsers: "{n}명이 리노트했습니다" followedBySomeUsers: "{n}명에게 팔로우됨" flushNotification: "알림 이력을 초기화" + exportOfXCompleted: "{x} 추출에 성공했습니다." _types: all: "전부" note: "사용자의 새 글" @@ -2360,6 +2409,8 @@ _notification: followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여 됨" achievementEarned: "도전 과제 획득" + exportCompleted: "추출을 성공함" + test: "알림 테스트" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" @@ -2411,6 +2462,7 @@ _webhookSettings: modifyWebhook: "Webhook 수정" name: "이름" secret: "시크릿" + trigger: "트리거" active: "활성화" _events: follow: "누군가를 팔로우했을 때" @@ -2425,11 +2477,12 @@ _webhookSettings: abuseReportResolved: "받은 신고를 처리했을 때" userCreated: "유저가 생성되었을 때" deleteConfirm: "Webhook을 삭제할까요?" + testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다." _abuseReport: _notificationRecipient: createRecipient: "신고 수신자 추가" modifyRecipient: "신고 수신자 편집" - recipientType: "알림 수신 유형" + recipientType: "알림 종류" _recipientType: mail: "이메일" webhook: "Webhook" @@ -2437,7 +2490,7 @@ _abuseReport: mail: "모더레이터 권한을 가진 사용자의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" webhook: "지정한 SystemWebhook에 알림을 보냅니다 (신고를 받은 때와 해결했을 때에 송신)" keywords: "키워드" - notifiedUser: "신고 알림을 보낼 유저" + notifiedUser: "알릴 사용자" notifiedWebhook: "사용할 Webhook" deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: @@ -2483,6 +2536,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "신고 알림 수신자 생성" updateAbuseReportNotificationRecipient: "신고 알림 수신자 편집" deleteAbuseReportNotificationRecipient: "신고 알림 수신자 삭제" + deleteAccount: "계정을 삭제" + deletePage: "페이지를 삭제" + deleteFlash: "Play를 삭제" + deleteGalleryPost: "갤러리 포스트를 삭제" _fileViewer: title: "파일 상세" type: "파일 유형" @@ -2614,3 +2671,22 @@ _mediaControls: pip: "화면 속 화면" playbackRate: "재생 속도" loop: "반복 재생" +_contextMenu: + title: "컨텍스트 메뉴" + app: "애플리케이션" + appWithShift: "Shift 키로 애플리케이션" + native: "브라우저의 UI" +_embedCodeGen: + title: "임베디드 코드를 커스터마이즈" + header: "해더를 표시" + autoload: "자동으로 다음 코드를 실행 (비권장)" + maxHeight: "최대 높이" + maxHeightDescription: "최대 값을 무시하려면 0을 입력하세요. 위젯이 상하로 길어지는 것을 방지하려면, 임의의 값을 입력해 주세요." + maxHeightWarn: "높이 최대 값이 설정되어져 있지 않습니다(0). 의도적으로 설정 하지 않았다면 임의의 값을 설정해주세요." + previewIsNotActual: "미리보기로 표시할 수 있는 크기보다 큽니다. 실제로 넣은 코드의 표시가 다른 경우가 있습니다." + rounded: "외곽선을 둥글게 하기" + border: "외곽선에 테두리를 씌우기" + applyToPreview: "미리보기에 반영" + generateCode: "임베디드 코드를 만들기" + codeGenerated: "코드를 만들었습니다." + codeGeneratedDescription: "만들어진 코드를 웹 사이트에 붙여서 사용하세요." diff --git a/locales/ko-NK.yml b/locales/ko-NK.yml new file mode 100644 index 000000000000..f356bb194d56 --- /dev/null +++ b/locales/ko-NK.yml @@ -0,0 +1,2510 @@ +--- +_lang_: "한국어(냥체)" +headlineMisskey: "노트로 연결되는 네트워크" +introMisskey: "환영한다냥! Misskey는 오픈 소스 분산형 마이크로 블로그 서비스다냥.\n'노트'를 작성해서 지금 일어냐고 있는 일을 공유하거냐, 당신만의 이야기를 모두에게 발신하면 된다냥. 📡\n'리액션' 기능으로 친구의 노트에 총알같이 반응을 추가할 수도 있다냥. 👍\n새로운 세계를 탐험해 보라냥! 🚀" +poweredByMisskeyDescription: "{name} 서버는 오픈소스 플랫폼 Misskey의 서버 가운데 하냐다냥." +monthAndDay: "{month}월 {day}일" +search: "검색" +notifications: "알림" +username: "유저명" +password: "비밀번호" +forgotPassword: "비밀번호를 까먹었다냥?!" +fetchingAsApObject: "실타래랑 노는 중..." +ok: "확인" +gotIt: "알겠다냥" +cancel: "취소" +noThankYou: "냐중에" +enterUsername: "유저명 입력" +renotedBy: "{user}님이 리노트" +noNotes: "노트가 없다냥..." +noNotifications: "표시할 알림이 없다냥!" +instance: "인스턴스" +settings: "설정" +notificationSettings: "알림 설정" +basicSettings: "기본 설정" +otherSettings: "기타 설정" +openInWindow: "창으로 열기" +profile: "프로필" +timeline: "타임라인" +noAccountDescription: "자기소개가 없다냥" +login: "로그인" +loggingIn: "로그인 중" +logout: "로그아웃" +signup: "회원 가입" +uploading: "업로드 중" +save: "저장" +users: "유저" +addUser: "유저 추가" +favorite: "즐겨찾기" +favorites: "즐겨찾기" +unfavorite: "즐겨찾기에서 제거" +favorited: "즐겨찾기에 등록했다냥!" +alreadyFavorited: "이미 즐겨찾기에 등록되어 있다냥!" +cantFavorite: "즐겨찾기에 등록하지 못했다냥..." +pin: "프로필에 고정" +unpin: "프로필에서 고정 해제" +copyContent: "내용 복사" +copyLink: "링크 복사" +copyLinkRenote: "리노트 링크 복사" +delete: "삭제" +deleteAndEdit: "삭제 후 편집" +deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집할거냥? 이 노트에 대한 리액션, 리노트, 답글들이 모두 삭제된다냥." +addToList: "리스트에 추가" +addToAntenna: "안테냐에 추가" +sendMessage: "메시지 보내기" +copyRSS: "RSS 복사" +copyUsername: "사용자 이름 복사" +copyUserId: "사용자 ID 복사" +copyNoteId: "노트 ID 복사" +copyFileId: "파일 ID 복사" +copyFolderId: "폴더 ID 복사" +copyProfileUrl: "프로필 URL 복사" +searchUser: "사용자 검색" +reply: "답글" +loadMore: "더 보기" +showMore: "더 보기" +showLess: "닫기" +youGotNewFollower: "새로운 팔로워가 있다냥." +receiveFollowRequest: "새로운 팔로우 요청이 기다리고 있다냥." +followRequestAccepted: "팔로우가 수락되었다냥!" +mention: "멘션" +mentions: "받은 멘션" +directNotes: "다이렉트 노트" +importAndExport: "가져오기와 내보내기" +import: "가져오기" +export: "내보내기" +files: "파일" +download: "다운로드" +driveFileDeleteConfirm: "‘{name}’ 파일을 삭제할거냥? 헤당 파일을 사용하는 일부 콘텐츠도 삭제된다냥." +unfollowConfirm: "{name}님을 진짜로 언팔로우할거냥?" +exportRequested: "내보내기를 요청했다냥. 이 작업은 시간이 걸릴 수도 있다냥. 파일은 촌장님이 \"드라이브\"에 넣어주겠다고 했다냥." +importRequested: "가져오기를 요청했다냥. 이 작업에는 시간이 꽤 걸릴 수도 있다냥." +lists: "리스트" +noLists: "리스트가 없다냥..." +note: "냥!" +notes: "노트" +following: "팔로잉" +followers: "팔로워" +followsYou: "냐를 팔로우하고 있다냥." +createList: "리스트 만들기" +manageLists: "리스트 관리" +error: "오류" +somethingHappened: "오류가 발생했다냥!" +retry: "다시 시도" +pageLoadError: "페이지를 불러오지 못했다냥..." +pageLoadErrorDescription: "네트워크 연결 또는 브라우저 캐시로 인해 발생했을 가능성이 높다냥. 캐시를 삭제하거냐, 잠시 후 다시 시도해 달라냥." +serverIsDead: "서버로부터 응답이 없었다냥. 잠시 후 다시 시도하는 건 어떨까냥?" +youShouldUpgradeClient: "이 페이지를 표시하려면, 새로고침하여 새로운 버전의 클라이언트를 이용해 달라냥." +enterListName: "리스트 이름을 입력해달라냥" +privacy: "프라이버시" +makeFollowManuallyApprove: "팔로우를 수동으로 승인" +defaultNoteVisibility: "기본 공개 범위" +follow: "팔로우" +followRequest: "팔로우 요청" +followRequests: "팔로우 요청" +unfollow: "팔로우 해제" +followRequestPending: "팔로우 허가 대기중" +enterEmoji: "이모지 입력" +renote: "리노트" +unrenote: "리노트 취소" +renoted: "리노트했다냥!" +cantRenote: "이 게시물은 리노트 할 수 없다냥..." +cantReRenote: "리노트를 리노트할 수는 없다냥..." +quote: "인용" +inChannelRenote: "채널 내 리노트" +inChannelQuote: "채널 내 인용" +pinnedNote: "고정된 노트" +pinned: "고정하기" +you: "냐" +clickToShow: "클릭하여 보기" +sensitive: "열람 주의" +add: "추가" +reaction: "리액션" +reactions: "리액션" +emojiPicker: "이모지 선택기" +pinnedEmojisForReactionSettingDescription: "리액션을 할 때 프로필에 고정하여 표시할 이모지를 설정할 수 있다냥." +pinnedEmojisSettingDescription: "이모지를 입력할 때 프로필에 고정하여 표시할 이모지를 설정할 수 있다냥." +emojiPickerDisplay: "선택기 표시" +overwriteFromPinnedEmojisForReaction: "리액션 설정을 덮어쓰기" +overwriteFromPinnedEmojis: "일반 설정을 덮어쓰기" +reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수도 있다냥." +rememberNoteVisibility: "공개 범위를 기억하기" +attachCancel: "첨부 취소" +deleteFile: "파일 삭제" +markAsSensitive: "열람주의로 설정" +unmarkAsSensitive: "열람주의 해제" +enterFileName: "파일명을 입력해달라냥" +mute: "뮤트" +unmute: "뮤트 해제" +renoteMute: "리노트 뮤트하기" +renoteUnmute: "리노트 뮤트 해제" +block: "차단" +unblock: "차단 해제" +suspend: "정지" +unsuspend: "정지 해제" +blockConfirm: "이 계정을 정말로 차단할거냥?" +unblockConfirm: "이 계정의 차단을 해제할거냥?" +suspendConfirm: "이 계정을 진짜 정지할거냥...?" +unsuspendConfirm: "이 계정의 정지를 해제할거냥?" +selectList: "리스트 선택" +editList: "리스트 편집" +selectChannel: "채널 선택" +selectAntenna: "안테냐 선택" +editAntenna: "안테냐 편집" +selectWidget: "위젯 선택" +editWidgets: "위젯 편집" +editWidgetsExit: "편집 종료" +customEmojis: "커스텀 이모지" +emoji: "이모지" +emojis: "이모지" +emojiName: "이모지 이름" +emojiUrl: "이모지 URL" +addEmoji: "이모지 추가" +settingGuide: "추천 설정" +cacheRemoteFiles: "리모트 파일을 캐시" +cacheRemoteFilesDescription: "이 설정을 활성화하면 리모트 파일을 이 서버의 스토리지에 캐시한다냥. 미디어의 표시가 빨라지지만, 서버의 저장 용량을 크게 소모한다냥. 리모트 유저의 미디어를 얼마냐 보관할 지는 역할의 드라이브 용량 제한에 따라 결정되며, 정해진 용량을 넘길 경우 오래된 파일부터 차례대로 삭제한 뒤 링크로 전환한다냥. \n비활성화하면 리모트 파일을 직접 링크하며, 이 경우 이미지 썸네일 생성 및 유저 프라이버시 보호를 위해 default.yml에서 proxyRemoteFiles를 true로 설정하는 것을 권장한다냥." +youCanCleanRemoteFilesCache: "파일 관리 화면의 🗑️ 버튼을 눌러 모든 캐시를 삭제할 수 있다냥." +cacheRemoteSensitiveFiles: "리모트의 민감한 파일을 캐시" +cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 리모트의 민감한 파일은 캐시하지 않고 리모트에서 직접 가져오도록 한다냥." +flagAsBot: "삐릭- 삐리릭?!" +flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 달라냥. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 봇 끼리의 무한 연쇄 반응을 회피하거냐, 이 계정의 시스템 상에서의 취급이 Bot 운영에 최적화되는 등의 변화가 생긴다냥." +flagAsCat: "미야아아아오오오오오오오오오옹!!!!!!!" +flagAsCatDescription: "야옹?! 냐는 고양이다냥!" +flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" +flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시한다냥." +autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" +addAccount: "계정 추가" +reloadAccountsList: "계정 리스트 정보 갱신" +loginFailed: "로그인에 실패했다냥..." +showOnRemote: "리모트에서 보기" +general: "일반" +wallpaper: "배경" +setWallpaper: "배경화면 설정" +removeWallpaper: "배경 제거" +searchWith: "검색: {q}" +youHaveNoLists: "리스트가 없다냥..." +followConfirm: "{name}님을 팔로우 할거냥?" +proxyAccount: "프록시 계정" +proxyAccountDescription: "프록시 계정은 특정 조건 하에서 유저의 리모트 팔로우를 대행하는 계정이다냥. 예를 들면, 유저가 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에, 대신 프록시 계정이 해당 유저를 팔로우하도록 한다냥." +host: "호스트" +selectUser: "유저 선택" +recipient: "수신인" +annotation: "내용에 대한 주석" +federation: "연합" +instances: "서버" +registeredAt: "등록 냘짜" +latestRequestReceivedAt: "마지막으로 요청을 받은 시간" +latestStatus: "마지막 상태" +storageUsage: "스토리지 사용량" +charts: "차트" +perHour: "1시간마다" +perDay: "1일마다" +stopActivityDelivery: "액티비티 보내지 않기" +blockThisInstance: "이 서버를 차단" +silenceThisInstance: "서버를 사일런스" +operations: "작업" +software: "소프트웨어" +version: "버전" +metadata: "메타데이터" +withNFiles: "{n}개의 파일" +monitor: "모니터" +jobQueue: "작업 대기열" +cpuAndMemory: "CPU와 메모리" +network: "네트워크" +disk: "디스크" +instanceInfo: "서버 정보" +statistics: "통계" +clearQueue: "대기열 비우기" +clearQueueConfirmTitle: "대기열을 비울 거냥?" +clearQueueConfirmText: "대기열에 냠아 있는 노트는 더 이상 연합되지 않는다냥. 보통의 경우 이 작업은 필요하지 않다냥." +clearCachedFiles: "캐시 비우기" +clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제할거냥?" +blockedInstances: "차단된 서버" +blockedInstancesDescription: "차단하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정한다냥. 차단된 인스턴스는 여기(이 인스턴스)와 통신할 수 없게 된다냥." +silencedInstances: "사일런스한 서버" +silencedInstancesDescription: "사일런스하려는 서버의 호스트명을 한 줄에 하냐씩 입력해달라냥. 사일런스된 서버에 소속된 유저는 모두 '사일런스'된 상태로 취급되며, 이 서버로부터의 팔로우가 프로필 설정과 무관하게 승인제로 변경되고, 팔로워가 아닌 로컬 유저에게는 멘션할 수 없게 된다냥. 정지된 서버에는 적용되지 않는다냥." +muteAndBlock: "뮤트 및 차단" +mutedUsers: "뮤트한 유저" +blockedUsers: "차단한 유저" +noUsers: "아무도 없습니다" +editProfile: "프로필 수정" +noteDeleteConfirm: "이 노트를 진짜로 삭제할거냥?" +pinLimitExceeded: "더 이상 고정할 수 없다냥..." +intro: "Misskey의 설치가 완료되었다냥! 관리자 계정을 생성해달라냥." +done: "완료" +processing: "처리중" +preview: "미리보기" +default: "기본값" +defaultValueIs: "기본값: {value}" +noCustomEmojis: "이모지가 없다냥..." +noJobs: "작업이 없다냥..." +federating: "연합 중" +blocked: "차단됨" +suspended: "정지됨" +all: "전체" +subscribing: "구독 중" +publishing: "배포 중" +notResponding: "응답 없음" +instanceFollowing: "서버의 팔로잉" +instanceFollowers: "서버의 팔로워" +instanceUsers: "서버의 유저" +changePassword: "비밀번호 변경" +security: "보안" +retypedNotMatch: "입력이 일치하지 않는다냥." +currentPassword: "현재 비밀번호" +newPassword: "새 비밀번호" +newPasswordRetype: "새 비밀번호(재입력)" +attachFile: "파일 첨부" +more: "더 보기!" +featured: "유행" +usernameOrUserId: "유저명이냐 ID" +noSuchUser: "유저를 찾을 수 없다냥..." +lookup: "찾아보기" +announcements: "공지사항" +imageUrl: "이미지 URL" +remove: "삭제" +removed: "삭제했다냥...!" +removeAreYouSure: "\"{x}\" 을(를) 진짜 삭제할거냥?" +deleteAreYouSure: "\"{x}\" 을(를) 정말 삭제힐거냥?" +resetAreYouSure: "정말로 초기화 할 것이냥?" +areYouSure: "이대로 계속 진행할거냥?" +saved: "저장했다냥!" +messaging: "대화" +upload: "업로드" +keepOriginalUploading: "원본 이미지를 유지" +keepOriginalUploadingDescription: "이미지를 업로드할 때에 원본을 그대로 유지한다냥. 비활성화하면 업로드할 때 브라우저에서 웹 공개용 이미지를 생성한다냥." +fromDrive: "드라이브에서" +fromUrl: "URL로부터" +uploadFromUrl: "URL 업로드" +uploadFromUrlDescription: "업로드하려는 파일의 URL" +uploadFromUrlRequested: "업로드를 요청했다냥." +uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 수 있다냥." +explore: "둘러보기" +messageRead: "읽음" +noMoreHistory: "이것보다 과거의 기록이 없다냥..." +startMessaging: "대화 시작하기" +nUsersRead: "{n}명이 읽음" +agreeTo: "{0}에 동의" +agree: "동의합니다냥." +agreeBelow: "아래 내용에 동의합니다냥." +basicNotesBeforeCreateAccount: "기본적인 주의사항" +termsOfService: "이용 약관" +start: "시작하기" +home: "홈" +remoteUserCaution: "리모트 유저이기 때문에, 정보가 정확하지 않을 수 있다냥." +activity: "활동" +images: "이미지" +image: "이미지" +birthday: "생일" +yearsOld: "{age}세" +registeredDate: "등록일" +location: "장소" +theme: "테마" +themeForLightMode: "라이트 모드에서 사용할 테마" +themeForDarkMode: "다크 모드에서 사용할 테마" +light: "라이트" +dark: "다크" +lightThemes: "밝은 테마" +darkThemes: "어두운 테마" +syncDeviceDarkMode: "디바이스의 다크 모드 설정과 동기화" +drive: "드라이브" +fileName: "파일명" +selectFile: "파일 선택" +selectFiles: "파일 선택" +selectFolder: "폴더 선택" +selectFolders: "폴더 선택" +renameFile: "파일 이름 변경" +folderName: "폴더 이름" +createFolder: "폴더 만들기" +renameFolder: "폴더 이름 바꾸기" +deleteFolder: "폴더 삭제" +folder: "폴더" +addFile: "파일 추가" +emptyDrive: "드라이브가 비어 있다냥..." +emptyFolder: "폴더가 비어 있다냥..." +unableToDelete: "삭제할 수 없다냥..." +inputNewFileName: "바꿀 파일명을 입력해 달라냥" +inputNewDescription: "새 캡션을 입력해 달라냥" +inputNewFolderName: "바꿀 폴더명을 입력해 달라냥." +circularReferenceFolder: "지정한 폴더가 이동할 폴더의 하위에 있다냥." +hasChildFilesOrFolders: "이 폴더는 비어있지 않기 때문에 삭제할 수 없다냥." +copyUrl: "URL 복사" +rename: "이름 변경" +avatar: "아바타" +banner: "배너" +displayOfSensitiveMedia: "민감한 미디어 표시" +whenServerDisconnected: "서버와의 접속이 끊겼을 때" +disconnectedFromServer: "너무 졸리다냥..." +reload: "새로고침" +doNothing: "무시하기" +reloadConfirm: "새로고침 할거냥?" +watch: "관심 갖기" +unwatch: "관심 해제하기" +accept: "수락하기" +reject: "거절하기" +normal: "일반" +instanceName: "서버 이름" +instanceDescription: "서버 소개" +maintainerName: "관리자 이름" +maintainerEmail: "관리자 이메일" +tosUrl: "이용약관 URL" +thisYear: "올해" +thisMonth: "이달" +today: "오늘" +dayX: "{day}일" +monthX: "{month}월" +yearX: "{year}년" +pages: "페이지" +integration: "연동" +connectService: "계정 연동" +disconnectService: "계정 연동 해제" +enableLocalTimeline: "로컬 타임라인 활성화" +enableGlobalTimeline: "글로벌 타임라인 활성화" +disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있다냥." +registration: "등록" +enableRegistration: "신규 회원가입을 활성화" +invite: "초대" +driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" +driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량" +inMb: "메가바이트 단위" +bannerUrl: "배너 이미지 URL" +backgroundImageUrl: "배경 이미지 URL" +basicInfo: "기본 정보" +pinnedUsers: "고정된 유저" +pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적는다냥." +pinnedPages: "고정한 페이지" +pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하냐씩 적는다냥." +pinnedClipId: "고정할 클립의 ID" +pinnedNotes: "고정된 노트" +hcaptcha: "hCaptcha" +enableHcaptcha: "hCaptcha 활성화" +hcaptchaSiteKey: "사이트 키" +hcaptchaSecretKey: "시크릿 키" +mcaptcha: "mCaptcha" +enableMcaptcha: "mCaptcha 활성화" +mcaptchaSiteKey: "사이트 키" +mcaptchaSecretKey: "시크릿 키" +mcaptchaInstanceUrl: "mCaptcha 인스턴스 URL" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHA 활성화" +recaptchaSiteKey: "사이트 키" +recaptchaSecretKey: "시크릿 키" +turnstile: "Turnstile" +enableTurnstile: "Turnstile 활성화" +turnstileSiteKey: "사이트 키" +turnstileSecretKey: "시크릿 키" +avoidMultiCaptchaConfirm: "여러 Captcha를 사용하는 경우 간섭이 발생할 가능성이 있다냥. 다른 Captcha를 비활성화 하는게 어떻겠느냥? 취소를 눌러 여러 Captcha를 활성화한 상태로 두는 것도 가능하다냥." +antennas: "안테냐" +manageAntennas: "안테냐 관리" +name: "이름" +antennaSource: "받을 소스" +antennaKeywords: "받을 검색어" +antennaExcludeKeywords: "제외할 검색어" +antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정된다냥." +notifyAntenna: "새로운 노트를 알림" +withFileAntenna: "파일이 첨부된 노트만" +enableServiceworker: "ServiceWorker 사용" +antennaUsersDescription: "유저명을 한 줄에 한 명씩 적는다냥" +caseSensitive: "대소문자를 구분" +withReplies: "답글 포함" +connectedTo: "다음 계정에 연결되어 있다냥" +notesAndReplies: "글과 답글" +withFiles: "미디어" +silence: "사일런스" +silenceConfirm: "이 계정을 정말 사일런스로 설정할 것이냥?" +unsilence: "사일런스 해제" +unsilenceConfirm: "이 계정의 사일런스를 해제할거냥?" +popularUsers: "인기 유저" +recentlyUpdatedUsers: "최근 활동한 유저" +recentlyRegisteredUsers: "최근 가입한 유저" +recentlyDiscoveredUsers: "최근 발견한 유저" +exploreUsersCount: "{count}명의 유저가 있다냥" +exploreFediverse: "연합우주를 탐색" +popularTags: "인기 태그" +userList: "리스트" +about: "정보" +aboutMisskey: "Misskey에 대하여" +administrator: "관리자" +token: "토큰" +2fa: "2단계 인증" +setupOf2fa: "2단계 인증 설정" +totp: "인증 앱" +totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" +moderator: "모더레이터" +moderation: "조정" +moderationNote: "조정 기록" +addModerationNote: "조정 기록 추가하기" +moderationLogs: "모더레이션 로그" +nUsersMentioned: "{n}명이 언급함" +securityKeyAndPasskey: "보안 키 또는 패스 키" +securityKey: "보안 키" +lastUsed: "마지막 사용" +lastUsedAt: "마지막 사용: {t}" +unregister: "등록 해제" +passwordLessLogin: "비밀번호 없이 로그인" +passwordLessLoginDescription: "비밀번호를 사용하지 않고 보안 키 또는 패스 키 등으로만 로그인한다냥." +resetPassword: "비밀번호 재설정" +newPasswordIs: "새로운 비밀번호는 \"{password}\" 다냥!" +reduceUiAnimation: "UI의 애니메이션을 줄이기" +share: "공유" +notFound: "찾을 수 없다냥..." +notFoundDescription: "지정한 URL에 해당하는 페이지가 존재하지 않는다냥..." +uploadFolder: "기본 업로드 위치" +markAsReadAllNotifications: "모든 알림을 읽은 상태로 표시" +markAsReadAllUnreadNotes: "모든 글을 읽은 상태로 표시" +markAsReadAllTalkMessages: "모든 대화를 읽은 상태로 표시" +help: "도움말" +inputMessageHere: "여기에 메시지를 입력해달라냥" +close: "닫기" +invites: "초대" +members: "멤버" +transfer: "양도" +title: "제목" +text: "텍스트" +enable: "사용" +next: "다음" +retype: "다시 입력" +noteOf: "{user}의 노트" +quoteAttached: "인용함" +quoteQuestion: "인용해서 작성할까냥?" +noMessagesYet: "아직 대화가 없다냥..." +newMessageExists: "새 메시지가 있다냥!" +onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하냐까지다냥." +signinRequired: "로그인 해달라냥" +invitations: "초대" +invitationCode: "초대 코드" +checking: "확인하는 중이다냥..." +available: "사용 가능하다냥!" +unavailable: "사용할 수 없다냥." +usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있다냥." +tooShort: "너무 짧다냥." +tooLong: "너무 길다냥..." +weakPassword: "약한 비밀번호다냥..." +normalPassword: "좋은 비밀번호다냥." +strongPassword: "강한 비밀번호다냥!!!" +passwordMatched: "냐호! 일치한다냥!" +passwordNotMatched: "일치하지 않는다냥..." +signinWith: "{x}로 로그인" +signinFailed: "로그인할 수 없다냥... 사용자명과 비밀번호를 확인해달라냥." +or: "혹은" +language: "언어" +uiLanguage: "UI 표시 언어" +aboutX: "{x}에 대하여" +emojiStyle: "이모지 스타일" +native: "기본" +disableDrawer: "드로어 메뉴를 사용하지 않기" +showNoteActionsOnlyHover: "노트 액션 버튼을 마우스를 올렸을 때에만 표시" +noHistory: "기록이 없다냥" +signinHistory: "로그인 기록" +enableAdvancedMfm: "고급 MFM을 활성화" +enableAnimatedMfm: "움직임이 있는 MFM을 활성화" +doing: "기달려달라냥..." +category: "카테고리" +tags: "태그" +docSource: "이 문서의 소스" +createAccount: "계정 만들기" +existingAccount: "기존 계정" +regenerate: "재생성" +fontSize: "글자 크기" +mediaListWithOneImageAppearance: "이미지가 1개 뿐인 미디어 목록의 높이" +limitTo: "{x}로 제한" +noFollowRequests: "처리되지 않은 팔로우 요청이 없다냥!" +openImageInNewTab: "새 탭에서 이미지 열기" +dashboard: "대시보드" +local: "로컬" +remote: "리모트" +total: "합계" +weekOverWeekChanges: "지냔주보다" +dayOverDayChanges: "어제보다" +appearance: "모양" +clientSettings: "클라이언트 설정" +accountSettings: "계정 설정" +promotion: "홍보" +promote: "프로모션하기" +numberOfDays: "며칠동안" +hideThisNote: "이 노트를 숨기기" +showFeaturedNotesInTimeline: "타임라인에 추천 노트를 표시" +objectStorage: "오브젝트 스토리지" +useObjectStorage: "오브젝트 스토리지를 사용" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "오브젝트 (미디어) 참조 URL 을 만들 때 사용되는 URL이다냥. CDN 또는 프록시를 사용하는 경우 그 URL을 지정하고, 그 외의 경우 사용할 서비스의 가이드에 따라 공개적으로 액세스 할 수 있는 주소를 지정해 달라냥. 예를 들어, AWS S3의 경우 'https://.s3.amazonaws.com', GCS등의 경우 'https://storage.googleapis.com/' 와 같이 지정하면 된다냥." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "사용 서비스의 bucket명을 지정해달라냥." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "이 Prefix 의 디렉토리 아래에 파일이 저장된다냥." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "AWS S3의 경우 공란, 다른 서비스의 경우 각 서비스의 가이드에 맞게 endpoint를 설정해달라냥. '' 혹은 ':' 와 같이 지정한다냥." +objectStorageRegion: "Region" +objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해달라냥. 사용하는 서비스에 region 개념이 없는 경우 'us-east-1'으로 설정해달라냥. AWS 설정 파일 또는 환경 변수를 참조할 경우에는 비워달라냥." +objectStorageUseSSL: "SSL 사용" +objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF 로 설정해달라냥" +objectStorageUseProxy: "연결에 프록시를 사용" +objectStorageUseProxyDesc: "오브젝트 스토리지 API 호출시 프록시를 사용하지 않는 경우 OFF 로 설정해달라냥" +objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" +s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급한다냥. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해냥 할 수 있다냥." +serverLogs: "서버 로그" +deleteAll: "모두 삭제" +showFixedPostForm: "타임라인 상단에 글 작성란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" +withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 냐오게 하기" +newNoteRecived: "새 노트가 있다냥." +sounds: "소리" +sound: "소리" +listen: "듣기" +none: "없음" +showInPage: "페이지로 보기" +popout: "새 창으로 열기" +volume: "음량" +masterVolume: "마스터 볼륨" +notUseSound: "음소거 하기" +useSoundOnlyWhenActive: "Misskey가 활성화 되어져 있을 때만 소리 출력하기" +details: "자세히" +chooseEmoji: "이모지 선택" +unableToProcess: "작업을 완료할 수 없다냥" +recentUsed: "최근 사용" +install: "설치" +uninstall: "삭제" +installedApps: "인증된 애플리케이션" +nothing: "아무것도 없다냥" +installedDate: "승인한 냘짜" +lastUsedDate: "마지막 사용" +state: "상태" +sort: "정렬" +ascendingOrder: "오름차순" +descendingOrder: "내림차순" +scratchpad: "스크래치 패드" +scratchpadDescription: "스크래치 패드는 AiScript 의 테스트 환경을 제공한다냥. Misskey 와 상호 작용하는 코드를 작성, 실행 및 결과를 확인할 수 있다냥." +output: "출력" +script: "스크립트" +disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" +updateRemoteUser: "리모트 유저 정보 갱신" +unsetUserAvatar: "아바타 제거" +unsetUserAvatarConfirm: "아바타를 제거할까냥?" +unsetUserBanner: "배너 제거" +unsetUserBannerConfirm: "배너를 지울까냥?" +deleteAllFiles: "모든 파일 삭제" +deleteAllFilesConfirm: "모든 파일을 삭제할 거냥...?" +removeAllFollowing: "모든 팔로잉 해제" +removeAllFollowingDescription: "{host} 서버의 모든 팔로잉을 해제할 거다냥. 해당 서버가 더 이상 존재하지 않는 경우 등에 실행해달라냥." +userSuspended: "이 계정은 정지된 상태다냥." +userSilenced: "이 계정은 사일런스된 상태다냥." +yourAccountSuspendedTitle: "계정이 정지되었다냥." +yourAccountSuspendedDescription: "선생님의 계정은 서버의 이용 약관을 위반하거냐, 기타 다른 이유로 인해 정지되었다냥. 자세한 사항은 촌장에게 문의해 달라냥." +tokenRevoked: "유효하지 않은 토큰이다냥" +tokenRevokedDescription: "로그인 토큰이 비활성화 되었다냥. 다시 로그인 해달라냥." +accountDeleted: "계정이 삭제되었다냥." +accountDeletedDescription: "이 계정이 삭제되었다냥." +menu: "메뉴" +divider: "구분선" +addItem: "항목 추가" +rearrange: "정렬" +relays: "릴레이" +addRelay: "릴레이 추가" +inboxUrl: "Inbox 주소" +addedRelays: "추가된 릴레이" +serviceworkerInfo: "푸시 알림을 수행하려면 활성화해냥 한다냥." +deletedNote: "삭제된 노트" +invisibleNote: "비공개 노트" +enableInfiniteScroll: "자동으로 더 보기" +visibility: "공개 범위" +poll: "투표" +useCw: "내용 숨기기" +enablePlayer: "플레이어 열기" +disablePlayer: "플레이어 닫기" +expandTweet: "게시물 확장하기" +themeEditor: "테마 에디터" +description: "설명" +describeFile: "캡션 추가" +enterFileDescription: "캡션 입력" +author: "작성자" +leaveConfirm: "저장하지 않은 변경사항이 있다냥. 정말 취소할 것이냥?" +manage: "관리" +plugins: "플러그인" +preferencesBackups: "환경설정 백업" +deck: "덱" +undeck: "덱 해제" +useBlurEffectForModal: "모달에 흐림 효과 사용" +useFullReactionPicker: "모든 기능이 포함된 리액션 선택기 사용" +width: "폭" +height: "높이" +large: "크게" +medium: "보통" +small: "작게" +generateAccessToken: "액세스 토큰 생성" +permission: "권한" +adminPermission: "관리자 권한" +enableAll: "전체 선택" +disableAll: "전체 해제" +tokenRequested: "계정 접근 허용" +pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 된다냥." +notificationType: "알림 유형" +edit: "편집" +emailServer: "메일 서버" +enableEmail: "이메일 송신 기능 활성화" +emailConfigInfo: "가입 시 메일 주소 확인이냐 비밀번호 초기화 시에 사용된다냥." +email: "이메일" +emailAddress: "메일 주소" +smtpConfig: "SMTP 서버 설정" +smtpHost: "호스트" +smtpPort: "포트" +smtpUser: "사용자 이름" +smtpPass: "비밀번호" +emptyToDisableSmtpAuth: "SMTP 인증을 사용하지 않으려면 공란으로 비워둔다냥." +smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" +smtpSecureInfo: "STARTTLS 사용 시에는 해제한다냥." +testEmail: "이메일 전송 테스트" +wordMute: "단어 뮤트" +hardWordMute: "하드 단어 뮤트" +regexpError: "정규 표현식 오류" +regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했다냥:" +instanceMute: "서버 뮤트" +userSaysSomething: "{name}님이 무언가를 말했다냥..." +makeActive: "활성화" +display: "표시" +copy: "복사" +metrics: "통계" +overview: "요약" +logs: "로그" +delayed: "지연" +database: "데이터베이스" +channel: "채널" +create: "생성" +notificationSetting: "알림 설정" +notificationSettingDesc: "표시할 알림의 종류를 선택해달라냥." +useGlobalSetting: "글로벌 설정을 사용하기" +useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용된다냥. 비활성화하면 개별적으로 설정할 수 있게 된다냥." +other: "기타" +regenerateLoginToken: "로그인 토큰을 재생성" +regenerateLoginTokenDescription: "로그인할 때 사용되는 내부 토큰을 재생성한다냥. 일반적으로 이 작업을 실행할 필요는 없다냥. 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃된다냥." +theKeywordWhenSearchingForCustomEmoji: "맞춤 이모티콘을 검색할 때 키워드가 된다냥." +setMultipleBySeparatingWithSpace: "공백으로 구분하여 여러 개 설정할 수도 있다냥." +fileIdOrUrl: "파일 ID 또는 URL" +behavior: "동작" +sample: "예시" +abuseReports: "신고" +reportAbuse: "신고" +reportAbuseRenote: "리노트 신고하기" +reportAbuseOf: "{name}을 신고하기" +fillAbuseReportDescription: "신고하려는 이유를 자세히 알려달라냥. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해달라냥..." +abuseReported: "신고를 보냈다냥. 조속히 처리하겠다냥...!" +reporter: "신고자" +reporteeOrigin: "피신고자" +reporterOrigin: "신고자" +forwardReport: "리모트 서버에도 신고 내용을 보낸다냥" +forwardReportIsAnonymous: "리모트 서버에서는 냐의 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시된다냥." +send: "전송" +abuseMarkAsResolved: "해결됨으로 표시" +openInNewTab: "새 탭에서 열기" +openInSideView: "사이드뷰로 열기" +defaultNavigationBehaviour: "기본 탐색 동작" +editTheseSettingsMayBreakAccount: "이 설정을 변경하면 계정이 손상될 수 있다냥." +instanceTicker: "노트의 서버 정보" +waitingFor: "{x}을(를) 기다리고 있다냥..." +random: "무작위" +system: "시스템" +switchUi: "UI 전환" +desktop: "데스크탑" +clip: "클립" +createNew: "새로 만들기" +optional: "옵션" +createNewClip: "새 클립 만들기" +unclip: "클립 해제" +confirmToUnclipAlreadyClippedNote: "이 노트는 이미 \"{name}\" 클립에 포함되어 있다냥. 클립을 해제할 것이냥?" +public: "공개" +private: "비공개" +i18nInfo: "Misskey는 착한 고양이들에 의해 다양한 언어로 번역되고 있다냥. {link}에서 번역에 참가할 수 있다냥." +manageAccessTokens: "액세스 토큰 관리" +accountInfo: "계정 정보" +notesCount: "노트 수" +repliesCount: "답글 수" +renotesCount: "리노트 수" +repliedCount: "받은 답글 수" +renotedCount: "받은 리노트 수" +followingCount: "팔로우 수" +followersCount: "팔로워 수" +sentReactionsCount: "보낸 리액션 수" +receivedReactionsCount: "받은 리액션 수" +pollVotesCount: "투표한 횟수" +pollVotedCount: "투표받은 횟수" +yes: "예" +no: "아니오" +driveFilesCount: "드라이브 파일 개수" +driveUsage: "드라이브 사용량" +noCrawle: "검색엔진의 인덱싱 거부" +noCrawleDescription: "검색엔진에 사용자 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 된다냥." +lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공개 범위를 '팔로워'로 하지 않는 한 누구냐 당신의 노트를 볼 수 있다냥." +alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" +loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" +disableShowingAnimatedImages: "움직이는 이미지를 자동으로 재생하지 않음" +highlightSensitiveMedia: "미디어가 민감한 내용이라는 것을 알기 쉽게 표시" +verificationEmailSent: "확인 메일을 발송했다냥. 설정을 완료하려면 메일에 첨부된 링크를 확인해달라냥." +notSet: "설정되지 않음" +emailVerified: "메일 주소가 확인되었다냥! 고생 많았다냥." +noteFavoritesCount: "즐겨찾기한 노트 수" +pageLikesCount: "좋아요 한 Page 수" +pageLikedCount: "Page에 받은 좋아요 수" +contact: "연락처" +useSystemFont: "시스템 기본 글꼴을 사용" +clips: "클립" +experimentalFeatures: "실험실" +experimental: "실험실" +thisIsExperimentalFeature: "이 기능은 실험적인 기능이다냥. 사양이 변경되거냐 정상적으로 동작하지 않을 가능성도 있다냥." +developer: "삐삐쀼쀼" +makeExplorable: "\"발견하기\"에 내 계정 보이기" +makeExplorableDescription: "비활성화하면 \"발견하기\"에 냐의 계정을 표시하지 않는다냥." +showGapBetweenNotesInTimeline: "타임라인의 노트 사이를 띄워서 표시" +duplicate: "복제" +left: "왼쪽" +center: "가운데" +wide: "넓게" +narrow: "좁게" +reloadToApplySetting: "이 설정을 적용하려면 페이지를 새로고침해냥 한다냥. 바로 새로고침 할까냥?" +needReloadToApply: "변경 사항은 새로고침하면 적용된다냥." +showTitlebar: "타이틀 바를 표시하기" +clearCache: "캐시 비우기" +onlineUsersCount: "고양이 {n}마리가 현재 미스키에 출몰...!" +nUsers: "{n} 유저" +nNotes: "{n} 노트" +sendErrorReports: "오류 보고서 보내기" +sendErrorReportsDescription: "이 설정을 활성화하면, 문제가 발생했을 때 오류에 대한 상세 정보를 Misskey에 보내어 더 냐은 소프트웨어를 만드는 데에 도움을 줄 수도 있다냥." +myTheme: "내 테마" +backgroundColor: "배경 색" +accentColor: "강조 색상" +textColor: "문자 색" +saveAs: "다른 이름으로 저장" +advanced: "고급" +advancedSettings: "고급 설정" +value: "값" +createdAt: "생성된 냘짜" +updatedAt: "수정한 냘짜" +saveConfirm: "저장할까냥?" +deleteConfirm: "삭제할 것이냥?" +invalidValue: "올바른 값이 아니다냥..." +registry: "레지스트리" +closeAccount: "계정 폐쇄" +currentVersion: "현재 버전" +latestVersion: "최신 버전" +youAreRunningUpToDateClient: "사용 중인 클라이언트는 최신이다냥." +newVersionOfClientAvailable: "새로운 버전의 클라이언트를 이용할 수 있다냥." +usageAmount: "사용량" +capacity: "용량" +inUse: "사용중" +editCode: "코드 수정" +apply: "적용" +receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신하겠다냥!" +emailNotification: "메일 알림" +publish: "게시" +inChannelSearch: "채널에서 검색" +useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" +typingUsers: "{users} 님이 입력하고 있다냥.." +jumpToSpecifiedDate: "특정 냘짜로 이동" +showingPastTimeline: "과거의 타임라인을 표시하고 있다냥" +clear: "지우기" +markAllAsRead: "모두 읽은 상태로 표시" +goBack: "뒤로" +unlikeConfirm: "좋아요를 취소할까냥?" +fullView: "전체 화면" +quitFullView: "전체 화면 해제" +addDescription: "설명 추가" +userPagePinTip: "각 노트의 메뉴에서 「프로필에 고정」을 선택하는 것으로, 여기에 노트를 표시해 둘 수 있다냥." +notSpecifiedMentionWarning: "수신자가 선택되지 않은 멘션이 존재한다냥." +info: "정보" +userInfo: "유저 정보" +unknown: "알 수 없음" +onlineStatus: "온라인 상태" +hideOnlineStatus: "온라인 상태 숨기기" +hideOnlineStatusDescription: "온라인 상태를 숨기면, 검색과 같은 일부 기능에 영향을 미칠 수 있다냥." +online: "온라인" +active: "최근에 활동함" +offline: "오프라인" +notRecommended: "추천하지 않음" +botProtection: "Bot 방어" +instanceBlocking: "서버 차단" +selectAccount: "계정 선택" +switchAccount: "계정 바꾸기" +enabled: "활성화" +disabled: "비활성화" +quickAction: "빠른 동작" +user: "사용자" +administration: "관리" +accounts: "계정" +switch: "전환" +noMaintainerInformationWarning: "촌장의 정보가 등록되어 있지 않다냥." +noBotProtectionWarning: "Bot 방어가 설정되어 있지 않다냥!" +configure: "설정하기" +postToGallery: "갤러리에 업로드" +postToHashtag: "이 해시태그에 게시" +gallery: "갤러리" +recentPosts: "최근 포스트" +popularPosts: "인기 포스트" +shareWithNote: "노트로 공유" +ads: "광고" +expiration: "기한" +startingperiod: "시작 기간" +memo: "메모" +priority: "우선순위" +high: "높음" +middle: "보통" +low: "냦음" +emailNotConfiguredWarning: "메일 주소가 설정되어 있지 않다냥." +ratio: "비율" +previewNoteText: "본문 미리보기" +customCss: "CSS 사용자화" +customCssWarn: "이 설정은 기능을 알고 있는 경우에만 사용해냥 한다냥. 잘못된 값을 입력하면 클라이언트가 정상적으로 작동하지 않을 수 있다냥." +global: "글로벌" +squareAvatars: "프로필 아바타를 네모네모하게 표시" +sent: "전송" +received: "수신" +searchResult: "검색 결과" +hashtags: "해시태그" +troubleshooting: "문제 해결" +useBlurEffect: "UI에 흐림 효과 사용" +learnMore: "자세히" +misskeyUpdated: "Misskey가 업데이트 되었다냥! 냐호!" +whatIsNew: "패치 정보 보기" +translate: "번역" +translatedFrom: "{x}에서 번역" +accountDeletionInProgress: "계정 삭제 작업을 진행하고 있다냥..." +usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있다냥. 사용자명은 냐중에 변경할 수 없다냥." +aiChanMode: "아이 모드" +devMode: "개발자 모드" +keepCw: "CW 유지하기" +pubSub: "Pub/Sub 계정" +lastCommunication: "마지막 통신" +resolved: "처리함" +unresolved: "처리되지 않음" +breakFollow: "팔로워 해제" +breakFollowConfirm: "팔로우를 정말 해제할 거냥??" +itsOn: "켜져있다냥" +itsOff: "꺼져있다냥" +on: "켜짐" +off: "꺼짐" +emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기" +unread: "읽지 않음" +filter: "필터" +controlPanel: "제어판" +manageAccounts: "계정 관리" +makeReactionsPublic: "리액션 목록을 공개하기" +makeReactionsPublicDescription: "냐의 리액션을 누구냐 볼 수 있게 한다냥." +classic: "클래식" +muteThread: "글타래 뮤트" +unmuteThread: "글타래 뮤트 해제" +followingVisibility: "팔로우의 공개 범위" +followersVisibility: "팔로워의 공개 범위" +continueThread: "글타래 더 보기" +deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 된다냥. 정말 계속할 것이냥...?" +incorrectPassword: "비밀번호가 올바르지 않다냥." +voteConfirm: "\"{choice}\"에 투표할거냥?" +hide: "숨기기" +useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시" +welcomeBackWithName: "{name}님, 만냐서 반갑다냥!" +clickToFinishEmailVerification: "[{ok}]를 눌러 이메일 인증을 완료해달라냥." +overridedDeviceKind: "장치 유형" +smartphone: "스마트폰" +tablet: "태블릿" +auto: "자동" +themeColor: "테마 컬러" +size: "크기" +numberOfColumn: "한 줄에 보일 리액션의 수" +searchByGoogle: "검색" +instanceDefaultLightTheme: "서버 기본 라이트 테마" +instanceDefaultDarkTheme: "서버 기본 다크 테마" +instanceDefaultThemeDescription: "객체 형식({}로 감싼 형태)의 테마 코드를 입력해달라냥." +mutePeriod: "뮤트할 기간" +period: "기간" +indefinitely: "무기한" +tenMinutes: "10분" +oneHour: "1시간" +oneDay: "1일" +oneWeek: "일주일" +oneMonth: "1개월" +reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있다냥." +failedToFetchAccountInformation: "계정 정보를 가져오지 못했다냥..." +rateLimitExceeded: "요청 제한 횟수를 초과했다냥?!" +cropImage: "이미지 자르기" +cropImageAsk: "이미지를 자를 거냥?" +cropYes: "잘라내기" +cropNo: "그대로 사용" +file: "파일" +recentNHours: "최근 {n}시간" +recentNDays: "최근 {n}일" +noEmailServerWarning: "메일 서버가 설정되어 있지 않다냥." +thereIsUnresolvedAbuseReportWarning: "해결되지 않은 신고가 있다냥. 어서 일해라냥!" +recommended: "추천" +check: "체크" +driveCapOverrideLabel: "이 유저의 드라이브 용량을 변경" +driveCapOverrideCaption: "0 이하를 지정하면 해제된다냥." +requireAdminForView: "열람하려면 관리자 계정으로 로그인해냥 한다냥." +isSystemAccount: "시스템에 의해 자동으로 생성되어 관리되는 계정이다냥." +typeToConfirm: "계속하시려면 {x} 을 입력해달라냥" +deleteAccount: "계정 삭제" +document: "문서" +numberOfPageCache: "페이지 캐시 수" +numberOfPageCacheDescription: "숫자가 클 수록 편리성이 높아지지만, 시스템 자원과 메모리를 더 많이 사용한다냥." +logoutConfirm: "로그아웃 할 거냥?" +lastActiveDate: "마지막 이용" +statusbar: "상태바" +pleaseSelect: "선택해 달라냥" +reverse: "플립" +colored: "색 입히기" +refreshInterval: "업데이트 주기" +label: "라벨" +type: "종류" +speed: "속도" +slow: "느리게" +fast: "빠르게" +sensitiveMediaDetection: "민감한 미디어 탐지" +localOnly: "로컬에만" +remoteOnly: "리모트만" +failedToUpload: "업로드 실패" +cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없다냥." +cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없다냥..." +cannotUploadBecauseExceedsFileSizeLimit: "파일 크기가 너무 크기 때문에 업로드할 수 없다냥!?" +beta: "베타" +enableAutoSensitive: "자동 NSFW 탐지" +enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정한다냥. 이 기능을 해제하더라도, 서버 정책에 따라 자동으로 설정될 수 있따냥." +activeEmailValidationDescription: "유저가 입력한 메일 주소가 일회용 메일인지, 실제로 통신할 수 있는 지 엄격하게 검사한다냥. 해제할 경우 이메일 형식에 대해서만 검사한다냥." +navbar: "내비게이션 바" +shuffle: "셔플" +account: "계정" +move: "이동" +pushNotification: "푸시 알림" +subscribePushNotification: "푸시 알림 켜기" +unsubscribePushNotification: "푸시 알림 끄기" +pushNotificationAlreadySubscribed: "푸시 알림이 이미 켜져 있다냥" +pushNotificationNotSupported: "브라우저냐 서버에서 푸시 알림이 지원되지 않는다냥..." +sendPushNotificationReadMessage: "푸시 알림이냐 메시지를 읽은 뒤 푸시 알림을 삭제" +sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」이라는 알림이 잠깐 표시된다냥. 기기의 전력 소비량이 증가할 수도 있다냥." +windowMaximize: "최대화" +windowMinimize: "최소화" +windowRestore: "복구" +caption: "캡션" +loggedInAsBot: "삐빅- 삐리릭?" +tools: "도구" +cannotLoad: "불러오지 못했다냥..." +numberOfProfileView: "프로필 뷰 수" +like: "좋다냥!" +unlike: "좋아요 취소" +numberOfLikes: "좋아요 수" +show: "표시" +neverShow: "다시 보지 않기" +remindMeLater: "냐중에 알림" +didYouLikeMisskey: "혹시, Misskey가 마음에 드냥?" +pleaseDonate: "Misskey는 {host} 서버의 무료 소프트웨어다냥. 앞으로도 개발을 이어 냐가려면 후원이 절실히 필요하다냥!" +correspondingSourceIsAvailable: "소스 코드는 {anchor}에서 구경해볼 수 있다냥." +roles: "역할" +role: "역할" +noRole: "역할이 없다냥" +normalUser: "일반 사용자" +undefined: "정의되지 않음" +assign: "할당" +unassign: "할당 취소" +color: "색" +manageCustomEmojis: "커스텀 이모지 관리" +manageAvatarDecorations: "아바타 꾸미기 관리" +youCannotCreateAnymore: "더 이상 생성할 수 없다냥." +cannotPerformTemporary: "일시적으로 사용할 수 없다냥." +cannotPerformTemporaryDescription: "조작 횟수 제한을 초과하여 일시적으로 사용이 불과하다냥. 너무 빠른거 아니냥?" +invalidParamError: "매개변수 오류" +invalidParamErrorDescription: "요청 매개변수에 문제가 있다냥. 대부분의 경우 Misskey의 버그가 원인이지만, 입력 문자수가 너무 많았을 가능성 등도 있다냥." +permissionDeniedError: "작업이 거부되었다냥." +permissionDeniedErrorDescription: "이 작업을 수행할 권한이 없다냥." +preset: "프리셋" +selectFromPresets: "프리셋에서 선택" +achievements: "도전 과제" +gotInvalidResponseError: "서버의 응답이 올바르지 않다냥?!" +gotInvalidResponseErrorDescription: " 서버가 다운되었거냐 점검중일 가능성이 있다냥. 잠시후에 다시 시도해달라냥." +thisPostMayBeAnnoying: "이 게시물은 다른 유저에게 피해를 줄 가능성이 있다냥..." +thisPostMayBeAnnoyingHome: "홈에 게시" +thisPostMayBeAnnoyingCancel: "그만두기" +thisPostMayBeAnnoyingIgnore: "가보자고" +collapseRenotes: "이미 본 리노트를 간략화하기" +internalServerError: "내부 서버 오류" +internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했다냥." +copyErrorInfo: "오류 정보 복사" +joinThisServer: "이 서버에 가입" +exploreOtherServers: "다른 서버 찾기" +letsLookAtTimeline: "타임라인 구경하기" +disableFederationConfirm: "정말로 연합을 끌 것이냥?" +disableFederationConfirmWarn: "연합을 끄더라도 게시물이 비공개로 전환되는 것은 아니다냥. 대부분의 경우 연합을 비활성화할 필요가 없다냥." +disableFederationOk: "연합을 끄기" +invitationRequiredToRegister: "현재 이 서버는 비공개다냥. 회원가입을 하려면 초대 코드가 필요하다냥!" +emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않는다냥." +postToTheChannel: "채널에 게시하기" +cannotBeChangedLater: "냐중에 변경할 수 없다냥." +reactionAcceptance: "리액션 수신" +likeOnly: "좋아요만 받기" +likeOnlyForRemote: "리모트에서는 좋아요만 받기" +nonSensitiveOnly: "민감한 이모지를 제외하고 받기" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "민감한 이모지를 제외하고 받기(리모트에서는 좋아요만 받기)" +rolesAssignedToMe: "냐에게 할당된 역할" +resetPasswordConfirm: "정말로 비밀번호를 재설정할거냥?" +sensitiveWords: "민감한 단어" +sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제한다냥. 개행으로 구분하여 여러 개를 지정할 수 있다냥." +sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 된다냥." +prohibitedWords: "금지 워드" +prohibitedWordsDescription: "설정된 워드가 포함되는 노트를 작성하려고 하면, 에러가 발생하도록 한다냥. 줄바꿈으로 구분지어 복수 설정할 수 있다냥." +prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 된다냥." +hiddenTags: "숨긴 해시태그" +hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 한다냥. 줄 바꿈으로 하냐씩 냐눠서 설정할 수 있다냥." +notesSearchNotAvailable: "노트 검색을 이용할 수 없다냥." +license: "라이선스" +unfavoriteConfirm: "즐겨찾기를 해제할거냥?" +myClips: "내 클립" +drivecleaner: "드라이브 정리" +retryAllQueuesNow: "모든 큐를 다시 시도" +retryAllQueuesConfirmTitle: "지금 다시 시도할 것이냥?" +retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있다냥." +enableChartsForRemoteUser: "리모트 유저의 차트를 생성" +enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" +showClipButtonInNoteFooter: "노트 동작에 클립을 추가" +reactionsDisplaySize: "리액션 표시 크기" +limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기" +noteIdOrUrl: "노트 ID 및 URL" +video: "동영상" +videos: "동영상" +audio: "소리" +audioFiles: "소리" +dataSaver: "데이터 절약 모드" +accountMigration: "계정 이동" +accountMoved: "이 고양이는 다음 계정으로 이사했다냥:" +accountMovedShort: "이사한 계정이다냥." +operationForbidden: "사용할 수 없다냥..." +forceShowAds: "광고를 항상 표시" +addMemo: "메모 추가" +editMemo: "메모 편집" +reactionsList: "리액션 목록" +renotesList: "리노트 목록" +notificationDisplay: "알림 표시" +leftTop: "왼쪽 상단" +rightTop: "오른쪽 상단" +leftBottom: "왼쪽 하단" +rightBottom: "오른쪽 하단" +stackAxis: "냐열 방향" +vertical: "세로" +horizontal: "가로" +position: "위치" +serverRules: "서버 규칙" +pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 꼭 확인해달라냥." +pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의해달라냥." +continue: "계속" +preservedUsernames: "예약된 사용자명" +preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하냐씩 입력한다냥. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 된다냥. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않는다냥." +createNoteFromTheFile: "이 파일로 노트를 작성" +archive: "아카이브" +channelArchiveConfirmTitle: "{name} 을(를) 아카이브할 것이냥?" +channelArchiveConfirmDescription: "아카이브한 채널은 채널 목록과 검색 결과에 표시되지 않으며, 채널에 새로운 노트를 작성할 수 없게 된다냥." +thisChannelArchived: "이 채널은 아카이브되었다냥." +displayOfNote: "노트 표시" +initialAccountSetting: "초기 설정" +youFollowing: "팔로잉" +preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" +preventAiLearningDescription: "외부의 문장 생성 AI냐 이미지 생성 AI에 대해 제출한 노트냐 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구한다냥. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아니다냥." +options: "옵션" +specifyUser: "사용자 지정" +failedToPreviewUrl: "미리 볼 수 없음" +update: "업데이트" +rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할" +rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "역할을 지정하지 않으면, 누구냐 이 이모지를 리액션으로 사용할 수 있다냥." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "역할은 공개로 설정되어 있어냥 한다냥." +cancelReactionConfirm: "정말 리액션을 취소할거냥?" +changeReactionConfirm: "앗, 리액션을 변경할거냥?" +later: "냐중에" +goToMisskey: "Misskey로" +additionalEmojiDictionary: "이모지 추가 사전" +installed: "설치됨" +branding: "브랜딩" +enableServerMachineStats: "서버의 머신 사양을 공개하기" +enableIdenticonGeneration: "유저마다의 Identicon 생성 유효화" +turnOffToImprovePerformance: "이 기능을 끄면 성능이 향상될 수도 있다냥." +createInviteCode: "초대 코드 생성" +createWithOptions: "옵션을 지정하여 생성" +createCount: "초대 수" +inviteCodeCreated: "초대 코드 생성됨" +inviteLimitExceeded: "초대 코드 생성 한도를 초과했다냥..." +createLimitRemaining: "초대 한도: {limit}회 냠음" +inviteLimitResetCycle: " {time}시간 이내에 최대 {limit}개의 초대 코드를 생성할 수 있다냥." +expirationDate: "만료 냘짜" +noExpirationDate: "만료기간 없음" +inviteCodeUsedAt: "다음에 사용된 초대 코드" +registeredUserUsingInviteCode: "초대 코드 사용 대상" +waitingForMailAuth: "이메일 인증 보류 중" +inviteCodeCreator: "초대 코드 생성자" +usedAt: "사용 시각" +unused: "사용되지 않음" +used: "사용됨" +expired: "만료됨" +doYouAgree: "동의하는 것이냥?" +beSureToReadThisAsItIsImportant: "중요하니까 반드시 읽어달라냥." +iHaveReadXCarefullyAndAgree: "\"{x}\"의 내용을 읽고 동의한다냥." +dialog: "다이얼로그" +icon: "아바타" +forYou: "냐에게" +currentAnnouncements: "현재 공지사항" +pastAnnouncements: "과거 공지사항" +youHaveUnreadAnnouncements: "읽지 않은 공지사항이 있다냥." +useSecurityKey: "브라우저 또는 기기의 안내에 따라 보안 키 또는 패스키를 사용해 달라냥." +replies: "답글" +renotes: "리노트" +loadReplies: "답글 보기" +loadConversation: "대화 보기" +pinnedList: "고정된 리스트" +keepScreenOn: "기기 화면을 항상 켜기" +verifiedLink: "이 링크의 소유자임이 확인되었다냥." +notifyNotes: "새 노트 알림 켜기" +unnotifyNotes: "새 노트 알림 끄기" +authentication: "인증" +authenticationRequiredToContinue: "계속하려면 인증해달라냥" +dateAndTime: "일시" +showRenotes: "리노트 표시" +edited: "수정됨" +notificationRecieveConfig: "알림 설정" +mutualFollow: "맞팔로우" +fileAttachedOnly: "미디어를 포함한 노트만" +showRepliesToOthersInTimeline: "타임라인에 다른 사람에게 보내는 답글을 포함" +hideRepliesToOthersInTimeline: "타임라인에 다른 사람에게 보내는 답글을 포함하지 않음" +showRepliesToOthersInTimelineAll: "타임라인에 현재 팔로우 중인 사람 전원의 답글을 포함하게 하기" +hideRepliesToOthersInTimelineAll: "타임라인에 현재 팔로우 중인 사람 전원의 답글이 냐오지 않게 하기" +confirmShowRepliesAll: "이 조작은 되돌릴 수 없다냥! 정말로 타임라인에 현재 팔로우 중인 사람 전원의 답글이 냐오게 할거냥?" +confirmHideRepliesAll: "이 조작은 되돌릴 수 없다냥! 정말로 타임라인에 현재 팔로우 중인 모두의 답글이 냐오지 않게 할거냥?" +externalServices: "외부 서비스" +sourceCode: "소스 코드" +sourceCodeIsNotYetProvided: "소스 코드를 아직 제공하지 않고 있다냥. 이 문제를 해결하려면 관리자에게 문의해 달라냥." +repositoryUrl: "저장소 URL" +repositoryUrlDescription: "소스 코드를 공개한 저장소가 있는 경우, 그 URL을 적으면 된다냥. Misskey를 원본 그대로 (소스 코드를 어떤 식으로도 변경하지 않고) 쓰고 있는 경우 https://github.com/misskey-dev/misskey 라고 적으면 끝이다냥." +repositoryUrlOrTarballRequired: "저장소를 공개하지 않은 경우 대신 tarball을 제공할 필요가 있다냥. 세부사항은 .config/example.yml을 참조해 달라냥!" +feedback: "피드백" +feedbackUrl: "피드백 URL" +impressum: "운영자 정보" +impressumUrl: "운영자 정보 URL" +impressumDescription: "독일 등의 일부 냐라와 지역에서는 꼭 표시해냥 한다냥(Impressum)." +privacyPolicy: "개인정보 보호 정책" +privacyPolicyUrl: "개인정보 보호 정책 URL" +tosAndPrivacyPolicy: "약관 및 개인정보 보호 정책" +avatarDecorations: "아바타 장식" +attach: "붙이기" +detach: "빼기" +detachAll: "모두 빼기" +angle: "각도" +flip: "플립" +showAvatarDecorations: "아바타 장식 표시" +releaseToRefresh: "놓아서 새로고침" +refreshing: "새로고침 중" +pullDownToRefresh: "아래로 내려서 새로고침" +disableStreamingTimeline: "타임라인의 실시간 갱신을 무효화하기" +useGroupedNotifications: "알림을 그룹화하고 표시" +signupPendingError: "메일 주소 확인중에 문제가 발생했다냥. 링크의 유효기간이 지났을 가능성도 있다냥." +cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써냥 한다냥." +doReaction: "리액션 추가" +code: "문자열" +reloadRequiredToApplySettings: "설정을 적용하려면 새로고침을 해냥 한다냥." +remainingN: "냐머지: {n}" +overwriteContentConfirm: "현재 내용을 덮어쓰기 할거다냥. 계속 진행해도 괜찮냥?" +seasonalScreenEffect: "계절에 따른 효과 보이기" +decorate: "장식하기" +addMfmFunction: "장식 추가하기" +enableQuickAddMfmFunction: "상급자용 MFM 선택기 표시하기" +bubbleGame: "버블 게임" +sfx: "효과음" +soundWillBePlayed: "소리가 재생된다냥" +showReplay: "리플레이 보기" +replay: "리플레이" +replaying: "리플레이 중" +ranking: "랭킹" +lastNDays: "최근 {n}일" +backToTitle: "타이틀로 가기" +hemisphere: "거주 지역" +withSensitive: "민감한 파일이 포함된 노트 보기" +userSaysSomethingSensitive: "{name}의 민감한 파일이 포함된 게시물" +enableHorizontalSwipe: "스와이프하여 탭 전환" +surrender: "그만두기" +_bubbleGame: + howToPlay: "설명" + _howToPlay: + section1: "위치를 조정하여 상자에 버블을 떨어뜨린다냥." + section2: "같은 종류의 버블이 붙으면 다른 버블로 바뀌면서 점수를 얻게 된다냥." + section3: "상자에서 물건이 넘치면 게임 오버다냥. 상자에서 물건이 넘치지 않도록 하면서 물건을 융합하여 높은 점수를 획득하면 된다냥!" +_announcement: + forExistingUsers: "기존 유저에게만 알림" + forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시한다냥. 비활성화하면 게시 후에 가입한 유저에게도 표시된다냥." + needConfirmationToRead: "읽음으로 표시하기 전에 확인하기" + needConfirmationToReadDescription: "활성화하면 이 공지사항을 읽음으로 표시하기 전에 확인 알림창을 띄운다냥. '모두 읽음'의 대상에서도 제외된다냥." + end: "공지에서 내리기" + tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 사용자 경험에 영향을 끼칠 가능성이 있다냥. 오래된 공지사항은 아카이브하시는 것을 권장한다냥." + readConfirmTitle: "읽음으로 표시할 것이냥?" + readConfirmText: "\"{title}\"을(를) 읽음으로 표시한다냥." + shouldNotBeUsedToPresentPermanentInfo: "신규 유저의 이용 경험에 악영향을 끼칠 수 있으므로, 일시적인 알림 수단으로만 사용하고 고정된 정보에는 사용을 지양하는 것을 추천한다냥." + dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 사용자 경험에 악영향을 끼칠 수 있으므로 신중히 결정해달라냥." + silence: "조용히 알림" + silenceDescription: "활성화하면 공지사항에 대한 알림이 가지 않게 되며, 확인 버튼을 누를 필요가 없게 된다냥." +_initialAccountSetting: + accountCreated: "계정 생성이 완료되었다냥!" + letsStartAccountSetup: "계정의 초기 설정을 진행한다냥." + letsFillYourProfile: "우선 사용자의 프로필을 설정해 달라냥." + profileSetting: "프로필 설정" + privacySetting: "프라이버시 설정" + theseSettingsCanEditLater: "이 설정들은 냐중에도 변경할 수 있다냥." + youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 냐의 입맛에 맞게 조절할 수 있다냥. 꼭 확인해 달라냥!" + followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸면 된다냥." + pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 냐의 기기에서 받아볼 수 있게 된다냥." + initialAccountSettingCompleted: "초기 설정을 모두 마쳤다냥!" + haveFun: "{name}와 함께 즐거운 시간 보내라냥!" + youCanContinueTutorial: "이대로 {name}(Misskey)의 사용법에 대해 튜토리얼을 진행할 수도 있지만, 여기서 중단하고 바로 시작할 수도 있다냥." + startTutorial: "튜토리얼 시작" + skipAreYouSure: "정말로 초기 설정을 중단할 거냥?" + laterAreYouSure: "초기 설정을 냐중에 진행할 것이냥?" +_initialTutorial: + launchTutorial: "튜토리얼 보기" + title: "튜토리얼" + wellDone: "잘했다냥!" + skipAreYouSure: "튜토리얼을 종료할 거냥?" + _landing: + title: "튜토리얼에 오신 걸 환영한다냥!" + description: "여기서는 미스키의 기본적인 사용법이냐 기능을 확인할 수 있다냥." + _note: + title: "'노트'가 무엇인가요?" + description: "미스키에서는 게시물을 '노트'라고 합니다. 노트는 타임라인에 시간순으로 정렬되어 있고, 사용자가 관련 설정을 끄지 않는 한 실시간으로 갱신된다냥." + reply: "답글을 달 수도 있다냥. 답글에 답글을 달 수도 있고 글타래처럼 대화를 이어갈 수 있다냥." + renote: "리노트로 노트를 자기 타임라인에 가져와서 공유하는 것이 가능하다냥. 글을 추가해서 인용하는 것도 역시 가능하다냥." + reaction: "리액션을 다는 것이 된다냥. 다음 페이지에서 자세한 설명을 볼 수 있다냥." + menu: "노트의 상세 정보를 표시하거냐, 링크를 복사하는 등의 다양한 조작을 할 수 있다냥." + _reaction: + title: "'리액션'이 무엇인가요?" + description: "노트에 '리액션'을 보낼 수 있다냥! '좋아요'만으로는 충분히 전해지지 않는 감정을, 이모지에 실어서 가볍게 보낼 수 있다냥." + letsTryReacting: "리액션은 노트의 '+' 버튼을 클릭하여 붙일 수 있다냥. 지금 표시되는 샘플 노트에 리액션을 달아 보는건 어떨까냥?" + reactToContinue: "다음으로 진행하려면 리액션을 달아달라냥." + reactNotification: "누군가가 냐의 노트에 리액션을 보내면 실시간으로 알림을 받게 된다냥." + reactDone: "'-' 버튼을 눌러서 리액션을 취소할 수도 있다냥." + _timeline: + title: "타임라인에 대하여" + description1: "Misskey에는 종류에 따라 여러 가지의 타임라인으로 구성되어 있다냥.(서버에 따라서는 일부 타임라인을 사용할 수 없는 경우도 있다냥)" + home: "'내가 팔로우 중인 계정'의 노트를 볼 수 있다냥." + local: "'이 서버에 있는 모든 유저'의 게시물을 볼 수 있다냥." + social: "'홈 타임라인과 로컬 타임라인'의 게시물을 모두 볼 수 있다냥." + global: "'이 서버와 연결되어 있는 모든 인스턴스'의 게시물을 볼 수 있다냥." + description2: "각각의 타임라인은 화면 상단에서 언제든지 변경할 수 있다냥." + description3: "이 외에도, '리스트 타임라인'이냐 '채널 타임라인' 등이 있다냥. 자세한 사항은 {link}에서 확인할 수 있다냥." + _postNote: + title: "노트 게시 설정" + description1: "Misskey에 노트를 쓸 때에는 다양한 옵션을 설정할 수 있다냥. 노트를 작성하는 화면은 이렇게 생겨먹었다냥." + _visibility: + description: "노트를 볼 수 있는 사람을 제한할 수 있다냥." + public: "모든 유저에게 공개된다냥." + home: "홈 타임라인에만 공개된다냥. 팔로워, 프로필 화면, 리노트를 통해서 다른 유저가 볼 수 있다냥." + followers: "팔로워에게만 공개. 자기 자신을 제외하고는 리노트가 불가능하며, 팔로워 외에는 열람할 수 없다냥." + direct: "지정한 유저에게만 공개되며, 상대방에게 알림이 간다냥. 다이렉트 메시지(DM)의 대응되는 기능이다냥." + doNotSendConfidencialOnDirect1: "민감한 정보를 보낼 때에는 주의해달라냥." + doNotSendConfidencialOnDirect2: "서버 관리자는 기술적으로는 게시물 내용을 열람이 가능하다냥. 신뢰할 수 없는 서버의 유저에게 다이렉트 메시지를 보내는 경우, 민감한 정보가 포함되어 있는 지 확인하고 전송해달라냥." + localOnly: "다른 서버에 게시물을 보내지 않는다냥. 앞서 설정한 공개 범위와 상관 없이, 다른 서버의 유저는 이 게시물을 직접 열람할 수 없게 된다냥." + _cw: + title: "내용 가리기 (CW)" + description: "본문 대신에 '내용에 대한 주석'에 입력한 텍스트가 먼저 표시된다냥. '더 보기' 버튼을 누르면 본문이 표시된다냥." + _exampleNote: + cw: "이 노트는 R-18을 포함하고 있음" + note: "완전 정말 엄청나게 엣찌한 내용의 노트다냥... 👀" + useCases: "서버의 가이드라인에 따라 특정 주제를 다룰 때에 사용하거냐, 스포일러 및 민감한 화제를 다룰 때에 사용하면 된다냥." + _howToMakeAttachmentsSensitive: + title: "첨부 파일을 열람주의로 설정하려면?" + description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 꼭 설정해 달라냥." + tryThisFile: "이 작성 창에 첨부된 이미지를 열람 주의로 바꿔 달라냥." + _exampleNote: + note: "냣또 뚜껑을 뜯다가 실수했다냥…" + method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭하면 된다냥." + sensitiveSucceeded: "파일을 첨부할 때에는 서버의 가이드라인에 따라 적절히 열람주의를 설정하면 되겠다냥." + doItToContinue: "이미지를 열람 주의로 설정하면 다음으로 넘어갈 수 있게 된다냥." + _done: + title: "튜토리얼이 끝났다냥! 🎉" + description: "여기에서 소개한 기능은 극히 일부에 지냐지 않는다냥. Misskey의 사용 방법을 더 자세히 알아보려면 {link}를 확인해 달라냥!" +_timelineDescription: + home: "홈 타임라인에서는, 내가 팔로우한 계정의 게시물을 볼 수 있다냥." + local: "로컬 타임라인에서는, 이 서버의 모든 유저의 게시물을 볼 수 있다냥." + social: "소셜 타임라인에서는, 홈 타임라인과 로컬 타임라인의 게시물을 모두 볼 수 있다냥." + global: "글로벌 타임라인에서는, 여기와 연결된 다른 모든 서버의 게시물을 볼 수 있다냥." +_serverRules: + description: "회원 가입 이전에 간단하게 표시할 서버 규칙이다냥. 이용 약관의 요약으로 구성하는 것을 추천한다냥." +_serverSettings: + iconUrl: "아이콘 URL" + appIconDescription: "{host}이 앱으로 표시될 때의 아이콘을 지정한다냥." + appIconUsageExample: "예를 들어, PWA냐 스마트폰 홈 화면에 북마크로 추가되었을 때 등" + appIconStyleRecommendation: "아이콘이 원형 또는 둥근 사각형으로 잘리는 경우가 있으므로, 가장자리 여백이 충분한 사진을 사용하는 것을 추천한다냥." + appIconResolutionMustBe: "해상도는 반드시 {resolution} 이어냥 한다냥." + manifestJsonOverride: "manifest.json 오버라이드" + shortName: "약칭" + shortNameDescription: "서버의 정식 명칭이 긴 경우에, 대신에 표시할 수 있는 약칭이냐 통칭." + fanoutTimelineDescription: "활성화하면 각종 타임라인을 가져올 때의 성능을 대폭 향상하며, 데이터베이스의 부하를 줄일 수 있다냥. 단, Redis의 메모리 사용량이 증가한다냥. 서버의 메모리 용량이 작거냐, 서비스가 불안정해지는 경우 비활성화할 수 있다냥." + fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기" + fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져온다냥. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정된다냥." +_accountMigration: + moveFrom: "다른 계정에서 이 계정으로 이사" + moveFromSub: "다른 계정에 대한 별칭을 생성" + moveFromLabel: "기존 계정 #{n}" + moveFromDescription: "다른 계정에서 이 계정으로 팔로워를 가져오려면, 우선 여기에서 별칭을 지정해냥 한다냥. '반드시 이사하기 전에 지정해냥 한다냥!' 기존 계정을 다음과 같은 형식으로 입력해 달라냥: @person@instance.com" + moveTo: "이 계정에서 다른 계정으로 이사" + moveToLabel: "이사할 계정:" + moveCannotBeUndone: "한 번 이사하면, 두 번 다시 되돌릴 수 없다냥." + moveAccountDescription: "새 계정으로 이전한다냥.\n ・팔로워가 새 계정을 자동으로 팔로우 합니다\n ・이 계정에서 팔로우는 모두 해제된다냥\n ・이 계정으로는 노트 작성 등을 할 수 없게 된다냥\n\n팔로워는 자동으로 이전되지만, 팔로우는 수동으로 진행해냥 한다냥. 이전하기 전에 이 계정에서 팔로우를 내보내고, 이전 후에는 즉시 이전한 계정에서 가져오기를 진행해달라냥.\n리스트・뮤트・차단에 대해서도 마찬가지이므로 수동으로 이전해냥 한다냥.\n\n(이 설명은 이 서버(Misskey v13.12.0 이후)의 사양이다냥. Mastodon 등의 다른 ActivityPub 소프트웨어에서는 작동이 다를 수 있다냥.)" + moveAccountHowTo: "계정을 이사하려면 우선 이사갈 계정에서 이 계정에 대한 별칭을 지정해냥 합니다.\n별칭을 작성한 다음, 이사갈 계정을 다음과 같이 입력하십시오:\n@username@server.example.com" + startMigration: "이사하기" + migrationConfirm: "정말로 이 계정을 {account} 으로 이전할 것이냥? 한 번 이전한 다음에는 취소할 수 없고, 두 번 다시 원래 상태로 복구할 수 없다냥.\n이사할 계정에서 계정 별칭을 지정하였는지 다시 한 번 확인해달라냥." + movedAndCannotBeUndone: "\n이사한 계정이다냥.\n이사는 취소할 수 없다냥." + postMigrationNote: "이 계정의 팔로잉 해제는 이사 후 24시간 뒤에 실행된다냥.\n이 계정의 팔로우 및 팔로워 수는 0으로 표시된다냥. 팔로워 해제는 이루어지지 않으므로, 당신의 팔로워는 이 계정의 팔로워 한정 게시물을 계속해서 열람할 수 있다냥." + movedTo: "이사할 계정:" +_achievements: + earnedAt: "달성 일시" + _types: + _notes1: + title: "미스키 계정 만들었다냥" + description: "첫 노트를 작성했다냥" + flavor: "Misskey에 어서 오세요!" + _notes10: + title: "몇 가지 노트" + description: "10개의 노트를 작성했다냥" + flavor: "화끈한 화제" + _notes100: + title: "많은 노트" + description: "100개의 노트를 작성했다냥" + _notes500: + title: "노트 범벅" + description: "500개의 노트를 작성했다냥" + _notes1000: + title: "노트가 산더미" + description: "1,000개의 노트를 작성했다냥" + _notes5000: + title: "솟아냐는 노트" + description: "5,000개의 노트를 작성했다냥" + _notes10000: + title: "슈퍼 노트" + description: "10,000개의 노트를 작성했다냥" + _notes20000: + title: "노트가 필요해요" + description: "20,000개의 노트를 작성했다냥" + _notes30000: + title: "노트노트노트" + description: "30,000개의 노트를 작성했다냥" + _notes40000: + title: "노트 공장" + description: "40,000개의 노트를 작성했다냥" + _notes50000: + title: "노트 행성" + description: "50,000개의 노트를 작성했다냥" + _notes60000: + title: "노트 퀘이사" + description: "60,000개의 노트를 작성했다냥" + _notes70000: + title: "노트 블랙홀" + description: "70,000개의 노트를 작성했다냥" + _notes80000: + title: "노트 은하" + description: "80,000개의 노트를 작성했다냥" + _notes90000: + title: "노트 우주" + description: "90,000개의 노트를 작성했다냥" + _notes100000: + title: "ALL YOUR NOTE ARE BELONG TO US" + description: "100,000개의 노트를 작성했다냥" + flavor: "이렇게냐 쓸 게 있어요?" + _login3: + title: "초보자 I" + description: "총 로그인한 냘이 3일" + flavor: "오늘부터 여러분도 미스키스트다냥!" + _login7: + title: "초보자 II" + description: "총 로그인한 냘이 7일" + flavor: "슬슬 익숙해진거냥?" + _login15: + title: "초보자 III" + description: "총 로그인한 냘이 15일" + _login30: + title: "미스키스트 I" + description: "총 로그인한 냘이 30일" + _login60: + title: "미스키스트 II" + description: "총 로그인한 냘이 60일" + _login100: + title: "미스키스트 III" + description: "총 로그인한 냘이 100일" + flavor: "그 유저, 미스키스트이다" + _login200: + title: "단골 I" + description: "총 200일간 로그인했다냥" + _login300: + title: "단골 II" + description: "총 300일간 로그인했다냥" + _login400: + title: "단골 III" + description: "총 400일간 로그인했다냥" + _login500: + title: "베테랑 I" + description: "총 500일간 로그인했다냥" + flavor: "제군, 냐는 노트가 좋다냥." + _login600: + title: "베테랑 II" + description: "총 600일간 로그인했다냥" + _login700: + title: "베테랑 III" + description: "총 700일간 로그인했다냥" + _login800: + title: "노트 마스터 I" + description: "총 800일간 로그인했다냥" + _login900: + title: "노트 마스터 II" + description: "총 900일간 로그인했다냥" + _login1000: + title: "노트 마스터 III" + description: "총 1,000일간 로그인했다냥" + flavor: "Misskey를 열심히 사용해 주어서 감사하다냥...!" + _noteClipped1: + title: "클립할 수밖에 없었어" + description: "처음으로 노트를 클립했다냥" + _noteFavorited1: + title: "별을 바라보는 자" + description: "처음으로 노트를 즐겨찾기했다냥" + _myNoteFavorited1: + title: "별을 원하는 자" + description: "다른 사람이 당신의 노트를 즐겨찾기했다냥" + _profileFilled: + title: "준비 완료" + description: "프로필 설정을 완료했다냥" + flavor: "#가보자고" + _markedAsCat: + title: "냐는 고양이다냥!" + description: "계정을 고양이로 설정했다냥" + flavor: "냐냐냐냐냐냐아아아아앙!" + _following1: + title: "첫 팔로우" + description: "사용자를 처음으로 팔로우했다냥" + _following10: + title: "팔로우, 팔로우" + description: "10명의 사용자를 팔로우했다냥" + _following50: + title: "친구 잔뜩" + description: "50명의 사용자를 팔로우했다냥" + _following100: + title: "주소록 한 권으론 부족해" + description: "100명의 사용자를 팔로우했다냥" + _following300: + title: "친구가 넘쳐냐" + description: "300명의 사용자를 팔로우했다냥" + _followers1: + title: "첫 팔로워" + description: "사용자가 처음으로 팔로잉했다냥" + _followers10: + title: "팔로우 미!" + description: "10명의 사용자가 팔로우했다냥" + _followers50: + title: "이곳저곳" + description: "50명의 사용자가 팔로우했다냥" + _followers100: + title: "인기왕" + description: "100명의 사용자가 팔로우했다냥" + _followers300: + title: "줄 좀 서봐요" + description: "100명의 사용자가 팔로우했다냥" + _followers500: + title: "기지국" + description: "500명의 사용자가 팔로우했다냥" + _followers1000: + title: "유명인사" + description: "1,000명의 사용자가 팔로우했다냥" + _collectAchievements30: + title: "도전 과제 콜렉터" + description: "30개의 도전과제를 획득했다냥" + _viewAchievements3min: + title: "저 도전과제 좋아해요" + description: "도전 과제 목록을 3분 이상 쳐다봤다냥" + _iLoveMisskey: + title: "I Love Misskey" + description: "\"I ❤ #Misskey\"를 포스트했다냥" + flavor: "Misskey를 이용해주셔서 감사합니다! - 개발팀 일동" + _foundTreasure: + title: "보물찾기" + description: "숨겨진 보물을 발견했다냥" + _client30min: + title: "잠시 쉬어요" + description: "클라이언트를 시작하고 30분이 경과했다냥..." + _client60min: + title: "No \"Miss\" in Misskey" + description: "클라이언트를 시작하고 60분이 결과했다냥...!" + _noteDeletedWithin1min: + title: "있었는데, 없었다냥." + description: "노트를 포스트한 후 1분 이내에 삭제했다냥" + _postedAtLateNight: + title: "올빼미" + description: "한밤중에 노트를 포스트했다냥" + flavor: "잠 좀 자야하는 거 아니냥? 걱정된다냥." + _postedAt0min0sec: + title: "정각" + description: "0분 0초 정각에 노트를 작성했다냥" + flavor: "째깍 째깍 째깍 땡!" + _selfQuote: + title: "혼잣말" + description: "자기 노트를 인용했다냥" + _htl20npm: + title: "타임라인 폭주 중" + description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었다냥" + _viewInstanceChart: + title: "애널리스트" + description: "서버의 차트를 열었다냥" + _outputHelloWorldOnScratchpad: + title: "Hello, world!" + description: "스크래치패드에서 hello world를 출력했다냥" + _open3windows: + title: "멀티 윈도우" + description: "3개 이상의 창을 열었다냥" + _driveFolderCircularReference: + title: "순환 참조" + description: "드라이브 폴더를 자신을 가리키도록 만드려 시도했다냥" + _reactWithoutRead: + title: "읽고 답하긴 하시는 건가요?" + description: "100자가 넘는 노트가 작성되고 3초 안에 반응했다냥" + _clickedClickHere: + title: "여기를 눌러달라냥" + description: "여기를 눌렀다냥" + _justPlainLucky: + title: "그냥 운이 좋았어" + description: "매 10초마다 0.01%의 확률로 달성된다냥?!" + _setNameToSyuilo: + title: "신 콤플렉스" + description: "이름을 syuilo로 설정했다냥" + _passedSinceAccountCreated1: + title: "1주년" + description: "계정을 생성하고 1년이 지났다냥" + _passedSinceAccountCreated2: + title: "2주년" + description: "계정을 생성하고 2년이 지났다냥" + _passedSinceAccountCreated3: + title: "3주년" + description: "계정을 생성하고 3년이 지났다냥" + _loggedInOnBirthday: + title: "생일 축하합니다!" + description: "생일에 로그인했다냥" + _loggedInOnNewYearsDay: + title: "새해 복 많이 받으세요" + description: "새해 첫 냘에 로그인했다냥" + flavor: "올해에도 우리 서버에 관심을 가져 주셔서 감사하다냥." + _cookieClicked: + title: "쿠키를 클릭하는 게임" + description: "쿠키를 클릭했다냥" + flavor: "소프트웨어를 착각하지는 않았냥??" + _brainDiver: + title: "Brain Diver" + description: "Brain Diver로의 링크를 첨부했다냥" + flavor: "Misskey-Misskey La-Tu-Ma" + _smashTestNotificationButton: + title: "테스트 과잉" + description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했다냥" + _tutorialCompleted: + title: "Misskey 입문자 과정 수료증" + description: "튜토리얼을 완료했다냥" + _bubbleGameExplodingHead: + title: "🤯" + description: "버블 게임에서 가장 큰 버블을 만들었다냥!" + _bubbleGameDoubleExplodingHead: + title: "더블 🤯" + description: "버블게임에서 가장 큰 물건 2개를 동시에 만들었다냥?!." + flavor: "이 정도만 도시락통에 🤯 🤯 조금만 더" +_role: + new: "새 역할 생성" + edit: "역할 수정" + name: "역할 이름" + description: "역할 설명" + permission: "역할 권한" + descriptionOfPermission: "조정자(모더레이터)는 기본적인 조정 작업을 진행할 수 있다냥.\n관리자는 서버의 모든 설정을 변경할 수 있다냥." + assignTarget: "할당 대상" + descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있다냥.\n조건부를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있다냥." + manual: "수동" + manualRoles: "수동 역할" + conditional: "조건부" + conditionalRoles: "조건부 역할" + condition: "조건" + isConditionalRole: "조건부 역할입니다." + isPublic: "역할 공개" + descriptionOfIsPublic: "역할에 할당된 사용자를 누구냐 볼 수 있다냥. 또한 사용자 프로필에 이 역할이 표시된다냥." + options: "옵션" + policies: "정책" + baseRole: "기본 역할" + useBaseValue: "기본값 사용" + chooseRoleToAssign: "할당할 역할 선택" + iconUrl: "아이콘 URL" + asBadge: "뱃지로 표시" + descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시된다냥." + isExplorable: "역할 타임라인 공개" + descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." + displayOrder: "표시 순서" + descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시된다냥." + canEditMembersByModerator: "모더레이터의 역할 수정 허용" + descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거냐 삭제할 수 있다냥. 꺼져 있으면 관리자만 할당이 가능하다냥." + priority: "우선순위" + _priority: + low: "냦음" + middle: "보통" + high: "높음" + _options: + gtlAvailable: "글로벌 타임라인 보이기" + ltlAvailable: "로컬 타임라인 보이기" + canPublicNote: "공개 노트 허용" + canInvite: "서버 초대 코드 발행" + inviteLimit: "초대 한도" + inviteLimitCycle: "초대 발급 간격" + inviteExpirationTime: "초대 만료 기간" + canManageCustomEmojis: "커스텀 이모지 관리" + canManageAvatarDecorations: "아바타 꾸미기 관리" + driveCapacity: "드라이브 용량" + alwaysMarkNsfw: "파일을 항상 NSFW로 지정" + pinMax: "고정할 수 있는 노트 수" + antennaMax: "최대 안테냐 생성 허용 수" + wordMuteMax: "단어 뮤트할 수 있는 문자 수" + webhookMax: "생성할 수 있는 웹훅 수" + clipMax: "생성할 수 있는 클립 수" + noteEachClipsMax: "각 클립에 추가할 수 있는 노트 수" + userListMax: "생성할 수 있는 유저 리스트 수" + userEachUserListsMax: "유저 리스트당 최대 사용자 수" + rateLimitFactor: "요청 빈도 제한" + descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." + canHideAds: "광고 숨기기" + canSearchNotes: "노트 검색 이용 가능 여부" + canUseTranslator: "번역 기능의 사용" + avatarDecorationLimit: "아바타 장식의 최대 붙임 개수" + _condition: + isLocal: "로컬 사용자" + isRemote: "리모트 사용자" + createdLessThan: "가입한 지 다음 일수 이내인 유저" + createdMoreThan: "가입한 지 다음 일수 이상인 유저" + followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" + followersMoreThanOrEq: "팔로워 수가 다음 이상인 유저" + followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" + followingMoreThanOrEq: "팔로잉 수가 다음 이상인 유저" + notesLessThanOrEq: "노트 수가 다음 이하인 유저" + notesMoreThanOrEq: "노트 수가 다음 이상인 유저" + and: "다음을 모두 만족" + or: "다음을 하냐라도 만족" + not: "다음을 만족하지 않음" +_sensitiveMediaDetection: + description: "기계 학습으로 민감한 미디어를 알아서 찾아내어 조정에 참고하도록 한다냥. 서버가 부하를 다소 받는다냥." + sensitivity: "탐지 민감도" + sensitivityDescription: "민감도가 냦을수록 안전한 미디어가 잘못 탐지될 확률이 줄어들며, 높을수록 민감한 미디어가 탐지되지 않을 확률이 줄어든다냥." + setSensitiveFlagAutomatically: "자동으로 NSFW로 설정하기" + setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지된다냥." + analyzeVideos: "동영상도 같이 확인하기" + analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지한다냥. 서버의 부하를 약간 증가시킬 수 있다냥." +_emailUnavailable: + used: "이 메일 주소는 사용중이다냥..." + format: "형식이 올바르지 않다냥!" + disposable: "임시 이메일 주소는 사용할 수 없다냥!" + mx: "메일 서버가 올바르지 않다냥!" + smtp: "메일 서버가 응답하지 않는다냥" + banned: "이 메일 주소는 사용할 수 없다냥!!!" +_ffVisibility: + public: "공개" + followers: "팔로워에게만 공개" + private: "비공개" +_signup: + almostThere: "거의 다 끝났다냥" + emailAddressInfo: "당신이 사용하고 있는 이메일 주소를 입력해 달라냥. 이메일 주소는 다른 유저에게 공개되지 공개되지 않는다냥." + emailSent: "입력하신 메일 주소({email})로 확인 메일을 보냈다냥. 가입을 완료하시려면 보내드린 메일에 있는 링크로 접속해 달라냥." +_accountDelete: + accountDelete: "계정 삭제" + mayTakeTime: "계정 삭제는 서버에 부하를 가하기 때문에, 작성한 콘텐츠냐 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있다냥." + sendEmail: "계정 삭제가 완료되면 등록된 이메일 주소로 알림을 보낸다냥." + requestAccountDelete: "계정 삭제 요청" + started: "삭제 작업이 시작되었다냥." + inProgress: "삭제 진행 중..." +_ad: + back: "뒤로" + reduceFrequencyOfThisAd: "이 광고의 표시 빈도 냦추기" + hide: "보이지 않음" + timezoneinfo: "요일은 서버의 표준 시간대에 따라 결정된다냥." + adsSettings: "광고 표시 설정" + notesPerOneAd: "실시간으로 갱신되는 타임라인에서 광고를 노출시키는 간격 (노트 당)" + setZeroToDisable: "0으로 지정하면 실시간 타임라인에서의 광고를 비활성화한다냥" + adsTooClose: "광고의 표시 간격이 매우 작아, 사용자 경험에 부정적인 영향을 미칠 수 있다냥." +_forgotPassword: + enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 달라냥. 입력한 메일 주소로 비밀번호 재설정 링크를 발송한다냥." + ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 달라냥." + contactAdmin: "이 서버에서는 메일 기능이 지원되지 않는다냥...! 비밀번호를 재설정하려면 관리자에게 문의해 달라냥." +_gallery: + my: "내 갤러리" + liked: "좋아요 한 갤러리" + like: "좋아요!" + unlike: "좋아요 취소" +_email: + _follow: + title: "새로운 팔로워가 있다냥" + _receiveFollowRequest: + title: "팔로우 요청을 받았다냥" +_plugin: + install: "플러그인 설치" + installWarn: "신뢰할 수 없는 플러그인은 설치하지 않는 것이 좋다냥." + manage: "플러그인 관리" + viewSource: "소스 보기" +_preferencesBackups: + list: "생성한 백업" + saveNew: "새 백업 만들기" + loadFile: "파일 가져오기" + apply: "이 기기에 적용" + save: "현재 설정으로 덮어쓰기" + inputName: "백업 이름을 입력해 달라냥" + cannotSave: "저장하지 못했다냥" + nameAlreadyExists: "\"{name}\" 백업이 이미 존재한다냥. 다른 이름을 설정해 달라냥." + applyConfirm: "\"{name}\" 백업을 현재 기기에 적용할 것이냥? 현재 설정은 덮어 씌워진다냥." + saveConfirm: "{name} 을 정말 덮어 쓸 것이냥?" + deleteConfirm: "{name} 을(를) 진짜로 삭제할 거냥?" + renameConfirm: "\"{old}\" 백업을 \"{new}\"(으)로 바꿀 것이냥?" + noBackups: "저장된 백업이 없다냥. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있다냥." + createdAt: "생성 냘짜: {date} {time}" + updatedAt: "갱신 냘짜: {date} {time}" + cannotLoad: "가져오기에 실패했다냥" + invalidFile: "파일 형식이 올바르지 않다냥." +_registry: + scope: "범위" + key: "키" + keys: "키" + domain: "도메인" + createKey: "키 생성" +_aboutMisskey: + about: "Misskey는 syuilo가 2014년부터 개발한 오픈소스 소프트웨어다냥." + contributors: "주요 기여자" + allContributors: "모든 기여자" + source: "소스 코드" + original: "원본" + thisIsModifiedVersion: "{name}에서는 원본 미스키를 수정한 버전을 사용하고 있다냥." + translation: "Misskey를 번역하기" + donate: "Misskey에 기부하기" + morePatrons: "이 외에도 다른 많은 분들이 도움을 주고 있다냥. 감사하다냥! 🥰" + patrons: "후원자" + projectMembers: "프로젝트 구성원" +_displayOfSensitiveMedia: + respect: "민감한 콘텐츠로 표시된 미디어 숨기기" + ignore: "민감한 콘텐츠로 표시된 미디어 보이기" + force: "미디어 항상 숨기기" +_instanceTicker: + none: "보이지 않음" + remote: "리모트 유저에게만 보이기" + always: "항상 보이기" +_serverDisconnectedBehavior: + reload: "자동으로 새로고침" + dialog: "경고창 표시" + quiet: "조용히 경고" +_channel: + create: "채널 생성" + edit: "채널 편집" + setBanner: "배너 설정" + removeBanner: "배너 삭제" + featured: "트렌드" + owned: "관리중" + following: "팔로잉" + usersCount: "{n}명 참여 중" + notesCount: "{n}노트" + nameAndDescription: "이름과 설명" + nameOnly: "이름만" + allowRenoteToExternal: "채널 외부로의 리노트와 인용 리노트를 허가" +_menuDisplay: + sideFull: "가로" + sideIcon: "가로(아이콘)" + top: "상단" + hide: "숨기기" +_wordMute: + muteWords: "뮤트할 단어" + muteWordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정한다냥." + muteWordsDescription2: "정규 표현식을 사용하려면 키워드를 빗금표(/)로 감싸 달라냥." +_instanceMute: + instanceMuteDescription: "뮤트한 서버에서 오는 답글을 포함한 모든 노트와 Renote를 뮤트한다냥." + instanceMuteDescription2: "한 줄에 하냐씩 입력해 달라냥" + title: "지정한 서버의 노트를 숨긴다냥." + heading: "뮤트할 서버" +_theme: + explore: "테마 둘러보기" + install: "테마 설치" + manage: "테마 관리" + code: "테마 코드" + description: "설명" + installed: "{name} 테마가 설치되었다냥" + installedThemes: "설치된 테마" + builtinThemes: "표준 테마" + alreadyInstalled: "이미 설치된 테마다냥" + invalid: "테마 형식이 올바르지 않다냥" + make: "테마 만들기" + base: "베이스" + addConstant: "상수 추가" + constant: "상수" + defaultValue: "기본값" + color: "색" + refProp: "프로퍼티를 참조" + refConst: "상수를 참조" + key: "키" + func: "함수" + funcKind: "함수 종류" + argument: "매개변수" + basedProp: "기준으로 할 속성 이름" + alpha: "불투명도" + darken: "어두움" + lighten: "밝음" + inputConstantName: "상수 이름을 입력해 달라냥" + importInfo: "여기에 테마 코드를 붙여 넣어 에디터로 불러올 수 있다냥." + deleteConstantConfirm: "상수 {const}를 삭제할 것이냥?" + keys: + accent: "강조 색상" + bg: "배경" + fg: "텍스트" + focus: "포커스" + indicator: "인디케이터" + panel: "패널" + shadow: "그림자" + header: "헤더" + navBg: "사이드바 배경" + navFg: "사이드바 텍스트" + navHoverFg: "사이드바 텍스트 (호버)" + navActive: "사이드바 텍스트 (활성)" + navIndicator: "사이드바 인디케이터" + link: "링크" + hashtag: "해시태그" + mention: "멘션" + mentionMe: "냐에게 보낸 멘션" + renote: "리노트" + modalBg: "모달 배경" + divider: "구분선" + scrollbarHandle: "스크롤바 핸들" + scrollbarHandleHover: "스크롤바 핸들 (호버)" + dateLabelFg: "냘짜 레이블 텍스트" + infoBg: "정보창 배경" + infoFg: "정보창 텍스트" + infoWarnBg: "경고창 배경" + infoWarnFg: "경고창 텍스트" + toastBg: "알림창 배경" + toastFg: "알림창 텍스트" + buttonBg: "버튼 배경" + buttonHoverBg: "버튼 배경 (호버)" + inputBorder: "입력 필드 테두리" + listItemHoverBg: "리스트 항목 배경 (호버)" + driveFolderBg: "드라이브 폴더 배경" + wallpaperOverlay: "배경화면 오버레이" + badge: "배지" + messageBg: "대화 배경" + accentDarken: "강조 색상 (어두움)" + accentLighten: "강조 색상 (밝음)" + fgHighlighted: "강조된 텍스트" +_sfx: + note: "새 노트" + noteMy: "내 노트" + notification: "알림" + antenna: "안테냐 수신" + channel: "채널 알림" + reaction: "리액션 선택" +_soundSettings: + driveFile: "드라이브에 있는 오디오를 사용" + driveFileWarn: "드라이브에 있는 파일을 선택해 달라냥." + driveFileTypeWarn: "이 파일은 지원되지 않습니다." + driveFileTypeWarnDescription: "오디오 파일을 선택해 달라냥." + driveFileDurationWarn: "오디오가 너무 깁니다" + driveFileDurationWarnDescription: "긴 오디오로 설정할 경우 미스키 사용에 지장이 갈 수도 있다냥. 그래도 괜찮냥?" +_ago: + future: "미래" + justNow: "방금 전" + secondsAgo: "{n}초 전" + minutesAgo: "{n}분 전" + hoursAgo: "{n}시간 전" + daysAgo: "{n}일 전" + weeksAgo: "{n}주 전" + monthsAgo: "{n}개월 전" + yearsAgo: "{n}년 전" + invalid: "없음" +_timeIn: + seconds: "{n}초 후" + minutes: "{n}분 후" + hours: "{n}시간 후" + days: "{n}일 후" + weeks: "{n}주 후" + months: "{n}개월 후" + years: "{n}년 후" +_time: + second: "초" + minute: "분" + hour: "시간" + day: "일" +_2fa: + alreadyRegistered: "이미 설정이 완료되었다냥." + registerTOTP: "인증 앱 설정 시작" + step1: "먼저, {a}냐 {b}등의 인증 앱을 사용 중인 디바이스에 설치한다냥." + step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔한다냥." + step2Click: "QR 코드를 클릭하면 기기에 설치된 인증 앱에 등록할 수 있다냥." + step2Uri: "데스크톱 앱을 사용하려면 다음 URI를 입력해 달라냥" + step3Title: "인증 코드 입력" + step3: "앱에 표시된 토큰을 입력하시면 완료된다냥." + setupCompleted: "설정 완료했다냥" + step4: "다음 로그인부터는 토큰을 입력해냥 한다냥." + securityKeyNotSupported: "이 브라우저는 보안 키를 지원하지 않는다냥." + registerTOTPBeforeKey: "보안 키 또는 패스키를 등록하려면 인증 앱을 등록해 달라냥." + securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이냐 화면잠금 PIN을 이용해서 로그인하도록 설정할 수 있다냥." + registerSecurityKey: "보안 키 또는 패스키 등록" + securityKeyName: "키 이름 입력" + tapSecurityKey: "브라우저의 지시에 따라 보안 키 또는 패스키를 등록해 달라냥" + removeKey: "보안 키를 삭제" + removeKeyConfirm: "{name} 을(를) 삭제할 거냥?" + whyTOTPOnlyRenew: "보안 키가 등록되어 있는 경우 인증 앱을 해제할 수 없다냥." + renewTOTP: "인증 앱 재설정" + renewTOTPConfirm: "기존에 등록되어 있던 인증 키는 사용하지 못하게 된다냥." + renewTOTPOk: "재설정" + renewTOTPCancel: "취소" + checkBackupCodesBeforeCloseThisWizard: "이 창을 닫기 전에 아래 백업 코드를 확인해 달라냥" + backupCodes: "백업 코드" + backupCodesDescription: "인증 앱을 사용할 수 없게 된 경우 아래 백업 코드를 사용하여 계정에 액세스 할 수 있다냥. 이 코드들은 반드시 안전한 장소에 보관해 달라냥. 각 코드는 한 번만 사용할 수 있다냥." + backupCodeUsedWarning: "백업 코드가 사용되었다냥. 인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 달라냥..." + backupCodesExhaustedWarning: "백업 코드가 모두 사용되었다냥. 인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능하다냥. 인증 앱을 다시 등록해 달라냥." +_permissions: + "read:account": "계정의 정보를 본다냥" + "write:account": "계정의 정보를 변경한다냥" + "read:blocks": "차단 여부를 확인한다냥" + "write:blocks": "차단을 하거냐 해제한다냥" + "read:drive": "드라이브 보기" + "write:drive": "드라이브에 파일을 올리거냐, 이름을 변경하거냐, 삭제한다냥" + "read:favorites": "즐겨찾기 보기" + "write:favorites": "즐겨찾기에 추가하거냐 삭제한다냥" + "read:following": "팔로우 상태를 본다냥" + "write:following": "팔로우하거냐 팔로우를 해제한다냥" + "read:messaging": "대화를 읽는다냥" + "write:messaging": "대화를 시작하거냐 메시지를 보낸다냥" + "read:mutes": "뮤트 여부를 확인한다냥" + "write:mutes": "뮤트를 하거냐 해제한다냥" + "write:notes": "노트를 작성하거냐 삭제한다냥" + "read:notifications": "알림을 확인한다냥" + "write:notifications": "알림을 모두 읽음 처리한다냥" + "read:reactions": "리액션을 확인한다냥" + "write:reactions": "리액션을 추가하거냐 취소한다냥" + "write:votes": "투표를 한다냥" + "read:pages": "페이지를 본다냥" + "write:pages": "페이지를 수정한다냥" + "read:page-likes": "페이지의 좋아요를 확인한다냥" + "write:page-likes": "페이지에 좋아요를 추가하거냐 취소한다냥" + "read:user-groups": "사용자 그룹 보기" + "write:user-groups": "유저 그룹을 만들거냐, 초대하거냐, 이름을 변경하거냐, 양도하거냐, 삭제한다냥" + "read:channels": "채널을 보기" + "write:channels": "채널을 추가하거냐 삭제한다냥" + "read:gallery": "갤러리를 본다냥" + "write:gallery": "갤러리를 추가하거냐 삭제한다냥" + "read:gallery-likes": "갤러리의 좋아요를 확인한다냥" + "write:gallery-likes": "갤러리에 좋아요를 추가하거냐 취소한다냥" + "read:flash": "Play를 본다냥" + "write:flash": "Play를 조작한다냥" + "read:flash-likes": "Play의 좋아요를 본다냥" + "write:flash-likes": "Play의 좋아요를 조작한다냥" + "read:admin:abuse-user-reports": "사용자 신고 보기" + "write:admin:delete-account": "사용자 계정 삭제하기" + "write:admin:delete-all-files-of-a-user": "모든 사용자 파일 삭제하기" + "read:admin:index-stats": "데이터베이스 색인 정보 보기" + "read:admin:table-stats": "데이터베이스 테이블 정보 보기" + "read:admin:user-ips": "사용자 IP 주소 보기" + "read:admin:meta": "인스턴스 메타데이터 보기" + "write:admin:reset-password": "사용자 비밀번호 재설정하기" + "write:admin:resolve-abuse-user-report": "사용자 신고 처리하기" + "write:admin:send-email": "이메일 보내기" + "read:admin:server-info": "서버 정보 보기" + "read:admin:show-moderation-log": "조정 기록 보기" + "read:admin:show-user": "사용자 개인정보 보기" + "read:admin:show-users": "사용자 개인정보 보기" + "write:admin:suspend-user": "사용자 정지하기" + "write:admin:unset-user-avatar": "사용자 아바타 삭제하기" + "write:admin:unset-user-banner": "사용자 배너 삭제하기" + "write:admin:unsuspend-user": "사용자 정지 해제하기" + "write:admin:meta": "인스턴스 메타데이터 수정하기" + "write:admin:user-note": "조정 기록 수정하기" + "write:admin:roles": "역할 수정하기" + "read:admin:roles": "역할 보기" + "write:admin:relays": "릴레이 수정하기" + "read:admin:relays": "릴레이 보기" + "write:admin:invite-codes": "초대 코드 수정하기" + "read:admin:invite-codes": "초대 코드 보기" + "write:admin:announcements": "공지사항 수정하기" + "read:admin:announcements": "공지사항 보기" + "write:admin:avatar-decorations": "아바타 꾸미기 수정하기" + "read:admin:avatar-decorations": "아바타 꾸미기 보기" + "write:admin:federation": "연합 정보 수정하기" + "write:admin:account": "사용자 계정 수정하기" + "read:admin:account": "사용자 정보 보기" + "write:admin:emoji": "이모지 수정하기" + "read:admin:emoji": "이모지 보기" + "write:admin:queue": "작업 대기열 수정하기" + "read:admin:queue": "작업 대기열 정보 보기" + "write:admin:promo": "홍보 기록 수정하기" + "write:admin:drive": "사용자 드라이브 수정하기" + "read:admin:drive": "사용자 드라이브 정보 보기" + "read:admin:stream": "관리자용 Websocket API 사용하기" + "write:admin:ad": "광고 수정하기" + "read:admin:ad": "광고 보기" + "write:invite-codes": "초대 코드 만들기" + "read:invite-codes": "초대 코드 불러오기" + "write:clip-favorite": "클립의 좋아요 수정하기" + "read:clip-favorite": "클립의 좋아요 보기" + "read:federation": "연합 정보 불러오기" + "write:report-abuse": "위반 내용 신고하기" +_auth: + shareAccessTitle: "어플리케이션의 접근 허가" + shareAccess: "‘{name}’에서 계정에 접근하는 것을 정말 허용할 것이냥?" + shareAccessAsk: "이 애플리케이션이 계정에 접근하는 것을 진짜로 허용할 거냥?" + permission: "{name}에서 다음 권한을 요청했다냥" + permissionAsk: "이 앱은 다음의 권한을 요청한다냥" + pleaseGoBack: "앱으로 돌아가서 시도해 달라냥" + callback: "앱으로 돌아간다냥!" + denied: "접근이 거부되었다냥..." + pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인 해달라냥." +_antennaSources: + all: "모든 노트" + homeTimeline: "팔로우중인 유저의 노트" + users: "지정한 유저의 노트" + userList: "지정한 리스트에 속한 유저의 노트" + userBlacklist: "지정한 유저를 제외한 모든 노트" +_weekday: + sunday: "일요일" + monday: "월요일" + tuesday: "화요일" + wednesday: "수요일" + thursday: "목요일" + friday: "금요일" + saturday: "토요일" +_widgets: + profile: "프로필" + instanceInfo: "서버 정보" + memo: "스티커 메모" + notifications: "알림" + timeline: "타임라인" + calendar: "달력" + trends: "트렌드" + clock: "시계" + rss: "RSS 리더" + rssTicker: "RSS Ticker" + activity: "활동" + photos: "사진" + digitalClock: "디지털 시계" + unixClock: "UNIX 시계" + federation: "연합" + instanceCloud: "서버 구름" + postForm: "글 입력란" + slideshow: "슬라이드 쇼" + button: "버튼" + onlineUsers: "온라인 유저" + jobQueue: "작업 대기열" + serverMetric: "서버 통계" + aiscript: "AiScript 콘솔" + aiscriptApp: "AiScript 앱" + aichan: "아이" + userList: "유저 리스트" + _userList: + chooseList: "리스트 선택" + clicker: "클리커" + birthdayFollowings: "오늘이 생일인 사용자" +_cw: + hide: "숨기기" + show: "더 보기" + chars: "{count} 문자" + files: "{count} 파일" +_poll: + noOnlyOneChoice: "투표 항목이 최소 2개 필요하다냥!" + choiceN: "선택지 {n}" + noMore: "더 이상 추가할 수 없다냥" + canMultipleVote: "복수 응답을 허용한다냥" + expiration: "투표 기한" + infinite: "무기한" + at: "일시 지정" + after: "기간 지정" + deadlineDate: "기한" + deadlineTime: "시간" + duration: "기간" + votesCount: "{n}표" + totalVotes: "총 {n}표" + vote: "투표하기" + showResult: "결과 보기" + voted: "투표함" + closed: "종료됨" + remainingDays: "종료까지 앞으로 {d}일 {h}시간" + remainingHours: "종료까지 앞으로 {h}시간 {m}분" + remainingMinutes: "종료까지 앞으로 {m}분 {s}초" + remainingSeconds: "종료까지 앞으로 {s}초" +_visibility: + public: "공개" + publicDescription: "모든 유저에게 공개" + home: "홈" + homeDescription: "홈 타임라인에만 공개" + followers: "팔로워" + followersDescription: "팔로워에게만 공개" + specified: "다이렉트" + specifiedDescription: "지정한 유저에게만 공개" + disableFederation: "연합에 보내지 않기" + disableFederationDescription: "다른 서버로 보내지 않는다냥!" +_postForm: + replyPlaceholder: "이 노트에 답글..." + quotePlaceholder: "이 노트를 인용..." + channelPlaceholder: "채널에 게시하기..." + _placeholders: + a: "집에서 뒹굴뒹굴하는 건 정말로 최고다냥..." + b: "밖에서는 무슨 일이 일어냐고 있는 걸까냥?" + c: "고양이는 솔직하니까 어쩔 수 없다냥." + d: "역시 말하고 싶은 게 있는 거냥?" + e: "여기에 하고싶은 말을 적어달라냥" + f: "글 작성을 기다리고 있다냥..." +_profile: + name: "이름" + username: "유저명" + description: "자기소개" + youCanIncludeHashtags: "해시 태그도 포함할 수 있다냥." + metadata: "추가 정보" + metadataEdit: "추가 정보 편집" + metadataDescription: "프로필에 추가 정보를 표시할 수 있다냥" + metadataLabel: "라벨" + metadataContent: "내용" + changeAvatar: "아바타 이미지 변경" + changeBanner: "배너 이미지 변경" + verifiedLinkDescription: "내용에 자신의 프로필로 향하는 링크가 포함된 페이지의 URL을 삽입하면 소유자 인증 마크가 표시된다냥." + avatarDecorationMax: "프로필 장식은 최대 {max}개 까지다냥!" +_exportOrImport: + allNotes: "모든 노트" + favoritedNotes: "즐겨찾기한 노트" + clips: "클립" + followingList: "팔로잉" + muteList: "뮤트" + blockingList: "차단" + userLists: "리스트" + excludeMutingUsers: "뮤트한 유저 제외하기" + excludeInactiveUsers: "장기 비활성화 중인 계정 제외하기" + withReplies: "가져오기한 유저에 의한 답글을 타임라인에 포함" +_charts: + federation: "연합" + apRequest: "요청" + usersIncDec: "유저 수 증감" + usersTotal: "유저 수 합계" + activeUsers: "활성 유저 수" + notesIncDec: "노트 수 증감" + localNotesIncDec: "로컬 노트 수 증감" + remoteNotesIncDec: "리모트 노트 수 증감" + notesTotal: "노트 수 합계" + filesIncDec: "파일 수 증감" + filesTotal: "파일 수 합계" + storageUsageIncDec: "스토리지 사용량 증감" + storageUsageTotal: "스토리지 사용량 합계" +_instanceCharts: + requests: "요청" + users: "유저 수 증감" + usersTotal: "누적 유저 수" + notes: "노트 수 증감" + notesTotal: "누적 노트 수" + ff: "팔로잉/팔로워 증감" + ffTotal: "누적 팔로잉/팔로워 수" + cacheSize: "캐시 용량 증감" + cacheSizeTotal: "누적 캐시 용량" + files: "파일 수 증감" + filesTotal: "누적 파일 수" +_timelines: + home: "홈" + local: "로컬" + social: "소셜" + global: "글로벌" +_play: + new: "Play 만들기" + edit: "Play 수정하기" + created: "Play를 생성했다냥!" + updated: "Play를 갱신했다냥." + deleted: "Play를 삭제했다냥" + pageSetting: "Play 설정" + editThisPage: "이 Play를 수정" + viewSource: "소스 보기" + my: "냐의 Play" + liked: "좋아요 한 Play" + featured: "인기" + title: "제목" + script: "스크립트" + summary: "설명" +_pages: + newPage: "페이지 만들기" + editPage: "페이지 수정" + readPage: "소스 표시 중" + created: "페이지를 만들었다냥!" + updated: "페이지를 수정했다냥." + deleted: "페이지가 삭제되었다냥" + pageSetting: "페이지 설정" + nameAlreadyExists: "지정한 페이지 URL이 이미 존재한다냥?!" + invalidNameTitle: "유효하지 않은 페이지 URL이다냥..." + invalidNameText: "비어있지 않은지 확인해달라냥." + editThisPage: "이 페이지를 편집" + viewSource: "소스 보기" + viewPage: "페이지 보기" + like: "좋아요" + unlike: "좋아요 해제" + my: "냐의 페이지" + liked: "좋아요한 페이지" + featured: "인기" + inspector: "인스펙터" + contents: "콘텐츠" + content: "페이지 블록" + variables: "변수" + title: "제목" + url: "페이지 URL" + summary: "페이지 요약" + alignCenter: "가운데 정렬" + hideTitleWhenPinned: "프로필에 고정한 경우 타이틀을 표시하지 않는다냥" + font: "폰트" + fontSerif: "명조체" + fontSansSerif: "고딕체" + eyeCatchingImageSet: "썸네일 이미지를 설정" + eyeCatchingImageRemove: "썸네일 이미지를 삭제" + chooseBlock: "블록 추가" + selectType: "종류 선택" + contentBlocks: "콘텐츠" + inputBlocks: "입력" + specialBlocks: "특수" + blocks: + text: "텍스트" + textarea: "텍스트 영역" + section: "섹션" + image: "이미지" + button: "버튼" + note: "노트필기" + _note: + id: "노트 ID" + idDescription: "노트 URL을 붙여넣어 설정할 수도 있다냥." + detailed: "세부 정보 보기" +_relayStatus: + requesting: "대기 중" + accepted: "승인됨" + rejected: "거절됨" +_notification: + fileUploaded: "파일이 업로드되었다냥" + youGotMention: "{name}님이 멘션했다냥" + youGotReply: "{name}님이 답글했다냥" + youGotQuote: "{name}님이 인용했다냥" + youRenoted: "{name}님이 리노트했다냥" + youWereFollowed: "새로운 팔로워가 있다냥" + youReceivedFollowRequest: "새로운 팔로우 요청이 있다냥" + yourFollowRequestAccepted: "팔로우 요청이 수락되었다냥" + pollEnded: "투표 결과가 발표되었다냥" + newNote: "새 게시물" + unreadAntennaNote: "안테냐 {name}" + roleAssigned: "새 역할이다냥...!" + emptyPushNotificationMessage: "푸시 알림이 갱신되었다냥" + achievementEarned: "도전 과제를 달성했다냥!" + testNotification: "알림 테스트" + checkNotificationBehavior: "알림 표시를 체크하기" + sendTestNotification: "테스트 알림 보내기" + notificationWillBeDisplayedLikeThis: "미야아아아오오오오오오오오오옹!!!!!!!" + reactedBySomeUsers: "{n}명이 반응했다냥" + renotedBySomeUsers: "{n}명이 리노트했다냥" + followedBySomeUsers: "{n}명에게 팔로우됨" + _types: + all: "전부" + note: "유저의 새 게시물" + follow: "팔로잉" + mention: "멘션" + reply: "답글" + renote: "리노트" + quote: "인용" + reaction: "리액션" + pollEnded: "투표가 종료됨" + receiveFollowRequest: "팔로우 요청을 받았을 때" + followRequestAccepted: "팔로우 요청이 승인되었을 때" + roleAssigned: "새 역할이다냥!" + achievementEarned: "도전 과제 획득" + app: "연동된 앱을 통한 알림" + _actions: + followBack: "팔로우" + reply: "답글" + renote: "리노트" +_deck: + alwaysShowMainColumn: "메인 칼럼 항상 표시" + columnAlign: "칼럼 정렬" + addColumn: "칼럼 추가" + configureColumn: "칼럼 설정" + swapLeft: "왼쪽으로 이동" + swapRight: "오른쪽으로 이동" + swapUp: "위로 이동" + swapDown: "아래로 이동" + stackLeft: "왼쪽에 쌓기" + popRight: "오른쪽으로 빼기" + profile: "프로파일" + newProfile: "새 프로파일" + deleteProfile: "프로파일 삭제" + introduction: "칼럼을 조합해서 냐만의 인터페이스를 구성해보자냥!" + introduction2: "냐중에라도 화면 우측의 + 버튼을 눌러 새 칼럼을 추가할 수 있다냥." + widgetsIntroduction: "칼럼 메뉴의 \"위젯 편집\"에서 위젯을 추가해달라냥" + useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기" + usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용된다냥" + flexible: "폭 자동 조정" + _columns: + main: "메인" + widgets: "위젯" + notifications: "알림" + tl: "타임라인" + antenna: "안테냐" + list: "리스트" + channel: "채널" + mentions: "받은 멘션" + direct: "다이렉트" + roleTimeline: "역할 타임라인" +_dialog: + charactersExceeded: "최대 글자수를 최대하였다냥!? 현재 {current} / 최대 {min}" + charactersBelow: "최소 글자수 미만이다냥?! 현재 {current} / 최소 {min}" +_disabledTimeline: + title: "비활성화된 타임라인" + description: "현재 역할에서는 이 타임라인을 이용할 수 없다냥..." +_drivecleaner: + orderBySizeDesc: "크기가 큰 순" + orderByCreatedAtAsc: "등록일이 오래된 순" +_webhookSettings: + createWebhook: "Webhook 생성" + name: "이름" + secret: "시크릿" + events: "Webhook을 실행할 타이밍" + active: "활성화" + _events: + follow: "누군가를 팔로우했을 때" + followed: "누군가 냐를 팔로우했을 때" + note: "노트를 게시할 때" + reply: "답글을 받았을 때" + renote: "누군가 내 글을 리노트했을 때" + reaction: "누군가 내 노트에 리액션했을 때" + mention: "누군가 냐를 멘션했을 때" +_moderationLogTypes: + createRole: "역할 생성" + deleteRole: "역할 삭제" + updateRole: "역할 수정" + assignRole: "역할 할당" + unassignRole: "역할 해제" + suspend: "정지" + unsuspend: "정지 해제" + addCustomEmoji: "커스텀 이모지 추가" + updateCustomEmoji: "커스텀 이모지 수정" + deleteCustomEmoji: "커스텀 이모지 삭제" + updateServerSettings: "서버 설정 갱신" + updateUserNote: "조정 기록 갱신" + deleteDriveFile: "파일 삭제" + deleteNote: "노트 삭제" + createGlobalAnnouncement: "모든 공지사항 만들기" + createUserAnnouncement: "사용자 공지사항 만들기" + updateGlobalAnnouncement: "모든 공지사항 수정" + updateUserAnnouncement: "사용자 공지사항 수정" + deleteGlobalAnnouncement: "모든 공지사항 삭제" + deleteUserAnnouncement: "사용자 공지사항 삭제" + resetPassword: "비밀번호 재설정" + suspendRemoteInstance: "리모트 서버를 정지" + unsuspendRemoteInstance: "리모트 서버의 정지를 해제" + updateRemoteInstanceNote: "리모트 서버의 조정 기록 갱신" + markSensitiveDriveFile: "파일에 열람주의를 설정" + unmarkSensitiveDriveFile: "파일에 열람주의를 해제" + resolveAbuseReport: "신고 처리" + createInvitation: "초대 코드 생성" + createAd: "광고 생성" + deleteAd: "광고 삭제" + updateAd: "광고 수정" + createAvatarDecoration: "아바타 장식 만들기" + updateAvatarDecoration: "아바타 장식 수정" + deleteAvatarDecoration: "아바타 장식 삭제" + unsetUserAvatar: "유저 아바타 제거" + unsetUserBanner: "유저 배너 제거" +_fileViewer: + title: "파일 상세" + type: "파일 유형" + size: "파일 크기" + url: "URL" + uploadedAt: "업로드 냘짜" + attachedNotes: "첨부된 노트" + thisPageCanBeSeenFromTheAuthor: "이 페이지는 파일 소유자만 열람할 수 있다냥" +_externalResourceInstaller: + title: "외부 사이트로부터 설치" + checkVendorBeforeInstall: "제공자를 신뢰할 수 있는 경우에만 설치를 권장한다냥." + _plugin: + title: "이 플러그인을 설치할 거냥?" + metaTitle: "플러그인 정보" + _theme: + title: "이 테마를 설치할 거냥?" + metaTitle: "테마 정보" + _meta: + base: "기본 컬러 스키마" + _vendorInfo: + title: "제공자 정보" + endpoint: "참조한 엔드포인트" + hashVerify: "파일 무결성 확인" + _errors: + _invalidParams: + title: "파라미터가 부족하다냥..." + description: "외부 사이트로부터 데이터를 불러오기 위해 필요한 정보가 부족하다냥. URL을 다시 한 번 확인해달라냥." + _resourceTypeNotSupported: + title: "해당하는 외부 리소스는 지원되지 않는다냥..." + description: "외부 사이트의 해당 리소스는 지원되지 않습니다. 사이트 관리자에게 문의해달라냥." + _failedToFetch: + title: "데이터를 불러올 수 없다냥..." + fetchErrorDescription: "외부 사이트와의 통신에 실패했다냥. 여러 번 시도해도 동일한 오류가 표시되는 경우 사이트 관리자에게 문의해달라냥." + parseErrorDescription: "외부 사이트에서 불러온 데이터를 읽어들일 수 없다냥. 사이트 관리자에게 문의해달라냥." + _hashUnmatched: + title: "데이터가 올바르지 않다냥..." + description: "데이터의 무결성 확인에 실패하여, 보안을 위해 설치가 중단되었다냥. 사이트 관리자에게 문의해달라냥." + _pluginParseFailed: + title: "AiScript 오류" + description: "데이터를 성공적으로 불러왔으냐, AiScript 분석 과정에서 오류가 발생하여 읽어들일 수 없다냥. 플러그인 작성자에게 문의해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." + _pluginInstallFailed: + title: "플러그인 설치에 실패했다냥..." + description: "플러그인을 설치하는 도중 문제가 발생하였다냥. 다시 한 번 시도해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." + _themeParseFailed: + title: "테마 코드 분석 오류" + description: "데이터를 성공적으로 불러왔으냐, 테마 코드 분석 과정에서 오류가 발생하여 읽어들일 수 없다냥. 테마 작성자에게 문의해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." + _themeInstallFailed: + title: "테마를 설치하지 못했다냥..." + description: "테마를 설치하는 도중 문제가 발생하였다냥. 다시 한 번 시도해달라냥. 자세한 사항은 브라우저에 내장된 개발자 도구의 Javascript 콘솔에서 확인할 수 있다냥." +_dataSaver: + _media: + title: "미디어 불러오기" + description: "사진이냐 동영상을 자동으로 불러오지 않습니다. 숨겨 놓은 사진이냐 동영상은 누르면 불러온다냥." + _avatar: + title: "아이콘 이미지" + description: "아이콘 이미지의 애니메이션을 멈춘다냥. 애니메이션 이미지는 일반 이미지보다 파일 크기가 클 수 있으므로 데이터 사용량을 더 줄일 수 있다냥." + _urlPreview: + title: "URL 미리보기의 썸네일" + description: "URL 미리보기의 썸네일 이미지를 불러오지 않게 된다냥." + _code: + title: "문자열 강조" + description: "MFM 등으로 문자열 강조 기법을 사용할 때 누르기 전에는 불러오지 않는다냥. 문자열 강조에서는 강조할 언어마다 그 정의 파일을 불러와냥 하지만 이를 자동으로 불러오지 않으므로 데이터 사용량을 줄일 수 있다냥." +_hemisphere: + N: "북반구(한국은 이쪽에 속한다냥)" + S: "냠반구" + caption: "일부 클라이언트 설정에서 계절을 판단하기 위해 사용된다냥." +_reversi: + reversi: "리버시" + gameSettings: "대국 설정" + chooseBoard: "보드 선택" + blackOrWhite: "선공/후공" + blackIs: "{name}님이 흑(선공)" + rules: "규칙" + thisGameIsStartedSoon: "대국이 곧 시작된다냥!" + waitingForOther: "상대방의 준비가 완료되기를 기다리고 있다냥." + waitingForMe: "당신의 준비가 완료되기를 기다리고 있다냥." + waitingBoth: "준비..." + ready: "준비 완료" + cancelReady: "준비 다시 시작" + opponentTurn: "상대의 차례다냥" + myTurn: "냐의 차례다냥" + turnOf: "{name}의 차례다냥" + pastTurnOf: "{name}의 차례" + surrender: "기권" + surrendered: "기권에 의해" + timeout: "시간 초과" + drawn: "무승부" + won: "{name}의 승리" + black: "흑" + white: "백" + total: "합계" + turnCount: "{count}턴 째" + myGames: "내 대국" + allGames: "모두의 대국" + ended: "종료" + playing: "대국 중" + isLlotheo: "돌이 적은 사람이 승리 (로세오)" + loopedMap: "루프 지도" + canPutEverywhere: "어디에도 둘 수 있는 모드" + timeLimitForEachTurn: "1턴의 시간 제한" + freeMatch: "프리매치" + lookingForPlayer: "상대를 찾고 있다냥" + gameCanceled: "대국이 취소되었다냥" + shareToTlTheGameWhenStart: "대국 시작 시 타임라인에 대국을 게시" + iStartedAGame: "대국이 시작되었습니다! #MisskeyReversi" + opponentHasSettingsChanged: "상대방이 설정을 변경했다냥" + allowIrregularRules: "규칙변경 허가 (완전 자유)" + disallowIrregularRules: "규칙변경 없음" +_offlineScreen: + title: "오프라인 - 서버에 접속할 수 없다냥" + header: "서버에 접속할 수 없다냥" + diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 73eff0941a06..f586ff2bff57 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -492,7 +492,6 @@ uiLanguage: "Język wyświetlania UI" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Natywny" -disableDrawer: "Nie używaj menu w stylu szuflady" showNoteActionsOnlyHover: "Pokazuj akcje notatek tylko po najechaniu myszką" showReactionsCount: "Wyświetl liczbę reakcji na notatkę" noHistory: "Brak historii" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 2e26f6d12a4f..34de5066f340 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1,15 +1,15 @@ --- -_lang_: "Português" +_lang_: "日本語" headlineMisskey: "Uma rede ligada por notas" introMisskey: "Bem-vindo! O Misskey é um serviço de microblog descentralizado de código aberto.\nCrie \"notas\" para compartilhar o que está acontecendo agora ou para se expressar com todos à sua volta 📡\nVocê também pode adicionar rapidamente reações às notas de outras pessoas usando a função \"Reações\" 👍\nVamos explorar um novo mundo 🚀" -poweredByMisskeyDescription: "{name} é um dos servidores da plataforma de código aberto Misskey." +poweredByMisskeyDescription: "{name} é uma instância da plataforma de código aberto Misskey." monthAndDay: "{day}/{month}" search: "Pesquisar" notifications: "Notificações" username: "Nome de usuário" password: "Senha" forgotPassword: "Esqueci-me da senha" -fetchingAsApObject: "Buscando no Fediverso" +fetchingAsApObject: "Buscando no Fediverso..." ok: "OK" gotIt: "Entendi" cancel: "Cancelar" @@ -25,7 +25,7 @@ basicSettings: "Configurações básicas" otherSettings: "Outras configurações" openInWindow: "Abrir em um janela" profile: "Perfil" -timeline: "Linha do tempo" +timeline: "Cronologia" noAccountDescription: "Este usuário não tem uma descrição." login: "Iniciar sessão" loggingIn: "Iniciando sessão…" @@ -60,6 +60,7 @@ copyFileId: "Copiar o ID do arquivo" copyFolderId: "Copiar o ID da pasta" copyProfileUrl: "Copiar a URL do perfil" searchUser: "Pesquisar usuário" +searchThisUsersNotes: "Pesquisar as notas desse usuário" reply: "Responder" loadMore: "Carregar mais" showMore: "Ver mais" @@ -81,7 +82,7 @@ exportRequested: "A sua solicitação de exportação foi enviada. Isso pode lev importRequested: "A sua solicitação de importação foi enviada. Isso pode levar algum tempo." lists: "Listas" noLists: "Não possui nenhuma lista" -note: "Post" +note: "Publicar" notes: "Posts" following: "Seguindo" followers: "Seguidores" @@ -99,7 +100,7 @@ enterListName: "Insira um nome para a lista" privacy: "Privacidade" makeFollowManuallyApprove: "Pedidos de seguidores precisam ser aprovados" defaultNoteVisibility: "Visibilidade padrão" -follow: "Seguindo" +follow: "Seguir" followRequest: "Enviar pedido de seguidor" followRequests: "Pedidos de seguidor" unfollow: "Deixar de seguir" @@ -108,11 +109,14 @@ enterEmoji: "Inserir emoji" renote: "Repostar" unrenote: "Remover repostagem" renoted: "Repostado" +renotedToX: "Repostar em {name}." cantRenote: "Não é possível repostar esta postagem" cantReRenote: "Não pode repostar este repost" quote: "Citar" inChannelRenote: "Repostar no canal" inChannelQuote: "Citar no canal" +renoteToChannel: "Repostar em canal" +renoteToOtherChannel: "Repostar em outro canal" pinnedNote: "Nota fixada" pinned: "Fixar no perfil" you: "Você" @@ -121,9 +125,16 @@ sensitive: "Conteúdo sensível" add: "Adicionar" reaction: "Reações" reactions: "Reações" +emojiPicker: "Seleção de emoji" +pinnedEmojisForReactionSettingDescription: "Selecionar os emojis que serão fixados e exibidos ao reagir." +pinnedEmojisSettingDescription: "Selecionar os emojis que serão fixos e exibidos na seleção de emoji." +emojiPickerDisplay: "Janela de seleção de emoji" +overwriteFromPinnedEmojisForReaction: "Sobrescrever as opções de reação" +overwriteFromPinnedEmojis: "Sobrescrever as opções gerais" reactionSettingDescription2: "Arraste para reordenar, clique para excluir, pressione + para adicionar." rememberNoteVisibility: "Lembrar das configurações de visibilidade de notas" attachCancel: "Remover anexo" +deleteFile: "Excluir arquivo" markAsSensitive: "Marcar como sensível" unmarkAsSensitive: "Desmarcar como sensível" enterFileName: "Digite o nome do arquivo" @@ -144,6 +155,7 @@ editList: "Editar lista" selectChannel: "Selecionar canal" selectAntenna: "Selecione uma antena" editAntenna: "Editar antena" +createAntenna: "Criar uma antena" selectWidget: "Selecione um widget" editWidgets: "Editar widgets" editWidgetsExit: "Pronto" @@ -170,16 +182,21 @@ addAccount: "Adicionar Conta" reloadAccountsList: "Recarregar lista de contas" loginFailed: "Falha ao logar" showOnRemote: "Exibir remotamente" +continueOnRemote: "" +chooseServerOnMisskeyHub: "Escolher um servidor da Misskey Hub" +specifyServerHost: "Especificar uma instância diretamente" +inputHostName: "Insira o domínio" general: "Geral" wallpaper: "Papel de parede" setWallpaper: "Definir papel de parede" removeWallpaper: "Remover papel de parede" searchWith: "Buscar: {q}" youHaveNoLists: "Não tem nenhuma lista" -followConfirm: "Tem certeza que quer deixar de seguir {name}?" +followConfirm: "Tem certeza que quer seguir {name}?" proxyAccount: "Conta proxy" proxyAccountDescription: "Uma conta de proxy é uma conta que assume o acompanhamento remoto de um usuário sob certas condições específicas. Por exemplo, quando um usuário inclui um usuário remoto em uma lista, mas ninguém na lista está seguindo o usuário remoto, a atividade não é entregue ao servidor. Nesse caso, a conta de proxy entra em ação para seguir o usuário remoto em vez disso." host: "Host" +selectSelf: "Escolher manualmente" selectUser: "Selecionar usuário" recipient: "Destinatário" annotation: "Anotação" @@ -194,6 +211,8 @@ perHour: "Por Hora" perDay: "Por dia" stopActivityDelivery: "Parar a entrega de atividades" blockThisInstance: "Bloquear esta instância" +silenceThisInstance: "Silenciar essa instância" +mediaSilenceThisInstance: "Silenciar a mídia dessa instância" operations: "Operações" software: "Software" version: "Versão" @@ -213,6 +232,10 @@ clearCachedFiles: "Limpar o cache" clearCachedFilesConfirm: "Deseja excluir todos os arquivos remotos em cache?" blockedInstances: "Instância bloqueada" blockedInstancesDescription: "Configure os hosts dos servidores que deseja bloquear, separando-os por quebras de linha. Os servidores bloqueados não poderão interagir com este servidor, incluindo os subdomínios." +silencedInstances: "Instâncias silenciadas" +silencedInstancesDescription: "Liste o nome de hospedagem dos servidores que você deseja silenciar, separados por linha. Todas as contas desses servidores serão silenciada e poderão enviar solicitações para seguir, mas não poderão mencionar usuários locais sem segui-los. Isso não afetará servidores bloqueados." +mediaSilencedInstances: "Instâncias com mídia silenciadas" +mediaSilencedInstancesDescription: "Liste o nome de hospedagem dos servidores cuja mídia você deseja silenciar, separados por linha. Todas as contas desses servidores serão consideradas sensíveis e não poderão utilizar emojis personalizados. Isso não afetará servidores bloqueados." muteAndBlock: "Silenciar e bloquear" mutedUsers: "Usuários silenciados" blockedUsers: "Usuários bloqueados" @@ -249,7 +272,7 @@ more: "Mais!" featured: "Destaques" usernameOrUserId: "Nome de usuário ou ID do usuário" noSuchUser: "Usuário não encontrado" -lookup: "Buscando" +lookup: "Consultar" announcements: "Avisos" imageUrl: "URL da imagem" remove: "Remover" @@ -257,6 +280,7 @@ removed: "Removido" removeAreYouSure: "Deseja excluir \"{x}\"?" deleteAreYouSure: "Deseja excluir \"{x}\"?" resetAreYouSure: "Deseja reiniciar?" +areYouSure: "Tem certeza?" saved: "Salvo" messaging: "Chat" upload: "Fazer upload" @@ -272,7 +296,7 @@ explore: "Explorar" messageRead: "Lida" noMoreHistory: "Não existe histórico anterior" startMessaging: "Iniciar conversação" -nUsersRead: "{n} Pessoas leem" +nUsersRead: "{n} pessoas leram" agreeTo: "Eu concordo com {0}" agree: "Concordar" agreeBelow: "Eu concordo com o seguinte" @@ -288,7 +312,7 @@ birthday: "Aniversário" yearsOld: "{age} anos" registeredDate: "Data de registro" location: "Localização" -theme: "tema" +theme: "Tema" themeForLightMode: "Temas usados ​​no modo de luz" themeForDarkMode: "Temas usados ​​no modo escuro" light: "Claro" @@ -302,11 +326,13 @@ selectFile: "Selecione os arquivos" selectFiles: "Selecione os arquivos" selectFolder: "Selecionar uma pasta" selectFolders: "Selecionar uma pasta" +fileNotSelected: "Nenhuma pasta selecionada" renameFile: "Renomear ficheiro" folderName: "Nome da pasta" createFolder: "Criar pasta" renameFolder: "Renomear Pasta" deleteFolder: "Excluir pasta" +folder: "Pasta" addFile: "Adicionar arquivo" emptyDrive: "O drive está vazio" emptyFolder: "A pasta está vazia" @@ -368,8 +394,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "Ativar hCaptcha" hcaptchaSiteKey: "Chave do sítio ‘web’" hcaptchaSecretKey: "Chave secreta" +mcaptcha: "mCaptcha" +enableMcaptcha: "Habilitar mCaptcha" mcaptchaSiteKey: "Chave do sítio ‘web’" mcaptchaSecretKey: "Chave secreta" +mcaptchaInstanceUrl: "URL do servidor mCaptcha" recaptcha: "reCAPTCHA" enableRecaptcha: "Habilitar reCAPTCHA" recaptchaSiteKey: "Chave do sítio ‘web’" @@ -385,6 +414,7 @@ name: "Nome" antennaSource: "Origem de entrada" antennaKeywords: "Palavras-chave recebidas" antennaExcludeKeywords: "Palavras-chave negativas" +antennaExcludeBots: "Ignorar contas de bot" antennaKeywordsDescription: "Se você separá-lo com um espaço, será uma especificação AND, e se você separá-lo com uma quebra de linha, será uma especificação OR." notifyAntenna: "Notificar novas notas" withFileAntenna: "Apenas notas com arquivos anexados" @@ -417,6 +447,9 @@ totp: "Aplicativo Autenticador" totpDescription: "Digite a senha de uso único informado pelo aplicativo autenticador" moderator: "Moderador" moderation: "Moderação" +moderationNote: "Nota de moderação" +addModerationNote: "Adicionar nota de moderação" +moderationLogs: "Logs de moderação" nUsersMentioned: "Postado por {n} pessoas" securityKeyAndPasskey: "Chave de segurança / Chave de acesso" securityKey: "Chave de segurança" @@ -449,10 +482,12 @@ retype: "Digite novamente" noteOf: "Publicação de {user}" quoteAttached: "Com citação" quoteQuestion: "Anexar como citação?" +attachAsFileQuestion: "O texto na área de transferência é muito longo. Você gostaria de anexá-lo como um arquivo de texto?" noMessagesYet: "Sem conversas até o momento" newMessageExists: "Há uma nova mensagem" onlyOneFileCanBeAttached: "Apenas um arquivo pode ser anexado a uma mensagem" signinRequired: "É necessário se inscrever ou fazer login antes de continuar" +signinOrContinueOnRemote: "Para continuar, você precisa mover o seu servidor ou entrar/cadastrar-se nesse servidor." invitations: "Convidar" invitationCode: "Código de convite" checking: "Verificando..." @@ -474,8 +509,8 @@ uiLanguage: "Idioma de exibição da interface " aboutX: "Sobre {x}" emojiStyle: "Estilo de emojis" native: "Nativo" -disableDrawer: "Não mostrar o menu em formato de gaveta" showNoteActionsOnlyHover: "Exibir as ações da nota somente ao passar o cursor sobre ela" +showReactionsCount: "Ver o número de reações nas notas" noHistory: "Ainda não há histórico" signinHistory: "Histórico de acesso" enableAdvancedMfm: "Habilitar MFM avançado" @@ -524,10 +559,11 @@ objectStorageUseProxy: "Usar proxy" objectStorageUseProxyDesc: "Se você não usa proxy para conexão de API, desative-o." objectStorageSetPublicRead: "Definir 'public-read' ao fazer o upload" s3ForcePathStyleDesc: "Ao habilitar s3ForcePathStyle, o nome do bucket é especificado como parte do caminho em vez de ser o nome do host na URL. Isso pode ser necessário ao usar serviços auto-hospedados como o Minio." -serverLogs: "Registro do servidor" +serverLogs: "Logs do servidor" deleteAll: "Excluir tudo" showFixedPostForm: "Exibir o formulário de postagem na parte superior da linha do tempo" showFixedPostFormInChannel: "Exibir o campo de postagem na parte superior da linha do tempo (canais)" +withRepliesByDefaultForNewlyFollowed: "Incluir respostas por usuários recém-seguidos na linha do tempo por padrão" newNoteRecived: "Nova nota recebida" sounds: "Sons" sound: "Sons" @@ -537,6 +573,8 @@ showInPage: "Ver na página" popout: "Sair" volume: "Volume" masterVolume: "volume principal" +notUseSound: "Desabilitar som" +useSoundOnlyWhenActive: "Apenas reproduzir sons quando Misskey estiver aberto." details: "Detalhes" chooseEmoji: "Selecione um emoji" unableToProcess: "Não é possível concluir a operação" @@ -557,6 +595,10 @@ output: "Resultado" script: "Script" disablePagesScript: "Desabilitar scripts nas páginas" updateRemoteUser: "Atualizar informações do usuário remoto" +unsetUserAvatar: "Remover avatar" +unsetUserAvatarConfirm: "Você tem certeza de que deseja remover o avatar?" +unsetUserBanner: "Remover banner" +unsetUserBannerConfirm: "Você tem certeza de que deseja remover o banner?" deleteAllFiles: "Excluir todos os arquivos" deleteAllFilesConfirm: "Deseja excluir todos os arquivos?" removeAllFollowing: "Deseja remover todos os seguidores?" @@ -607,6 +649,7 @@ medium: "Médio" small: "Pequeno" generateAccessToken: "Gerar token de acesso" permission: "Permissões" +adminPermission: "Permissões de administrador" enableAll: "Habilitar tudo" disableAll: "Desabilitar tudo" tokenRequested: "Autorização de acesso à conta" @@ -628,6 +671,7 @@ smtpSecure: "Use SSL/TLS implícito para conexões SMTP" smtpSecureInfo: "Desative esta opção ao utilizar STARTTLS." testEmail: "Testar envio de e-mail" wordMute: "Silenciar palavras" +hardWordMute: "SIlenciamento pesado de palavra" regexpError: "Erro na expressão regular" regexpErrorDescription: "Ocorreu um erro na expressão regular na linha {line} da palavra mutada {tab}:" instanceMute: "Instâncias silenciadas" @@ -649,12 +693,13 @@ useGlobalSettingDesc: "Ao ativar, serão utilizadas as configurações de notifi other: "Outros" regenerateLoginToken: "Gerar novo token de login" regenerateLoginTokenDescription: "Gera novamente o token interno usado para o login. Normalmente, isso não é necessário. Ao regenerar, você será desconectado de todos os dispositivos." +theKeywordWhenSearchingForCustomEmoji: "Essa é a palavra-chave ao pesquisar por emojis personalizados" setMultipleBySeparatingWithSpace: "Você pode configurar vários itens separando-os por espaço." fileIdOrUrl: "ID do arquivo ou URL" behavior: "Comportamento" sample: "Exemplo" abuseReports: "Denúncias" -reportAbuse: "Denúncias" +reportAbuse: "Denunciar" reportAbuseRenote: "Reportar repostagem" reportAbuseOf: "Denunciar {name}" fillAbuseReportDescription: "Por favor, forneça detalhes sobre o motivo da denúncia. Se houver uma nota específica envolvida, inclua também a URL dela." @@ -708,6 +753,7 @@ lockedAccountInfo: "Mesmo que você defina a aprovação para seguir, a menos qu alwaysMarkSensitive: "Marcar como sensível por padrão" loadRawImages: "Exibir as imagens originais ao invés de miniaturas" disableShowingAnimatedImages: "Não reproduzir imagens animadas" +highlightSensitiveMedia: "Destacar mídia sensível" verificationEmailSent: "Um e-mail de confirmação foi enviado. Siga o link no e-mail para concluir a verificação." notSet: "Não definido" emailVerified: "O endereço de e-mail foi confirmado" @@ -721,7 +767,7 @@ experimentalFeatures: "Funcionalidades Experimentais" experimental: "Experimental" thisIsExperimentalFeature: "Este é um recurso experimental. As funções podem mudar ou pode não funcionar corretamente." developer: "Programador" -makeExplorable: "Deixe a sua conta mais fácil de encontrar." +makeExplorable: "Deixe a sua conta encontrável em \"Explorar\"." makeExplorableDescription: "Se você desativá-lo, outros usuários não poderão encontrar a sua conta na aba Descoberta." showGapBetweenNotesInTimeline: "Mostrar um espaço entre as notas na linha de tempo" duplicate: "Duplicar" @@ -796,11 +842,12 @@ switchAccount: "Trocar conta" enabled: "Ativado" disabled: "Desativado" quickAction: "Ações rápidas" -user: "Usuários" +user: "Usuário" administration: "Administrar" accounts: "Contas" switch: "Trocar" noMaintainerInformationWarning: "A informação de administrador não foi configurada." +noInquiryUrlWarning: "URL de consulta não está definida" noBotProtectionWarning: "A proteção contra bots não foi configurada." configure: "Configurar" postToGallery: "Criar publicação em galeria" @@ -860,6 +907,8 @@ makeReactionsPublicDescription: "Isto vai deixar o histórico de todas as suas r classic: "Clássico" muteThread: "Silenciar esta conversa" unmuteThread: "Desativar silêncio desta conversa" +followingVisibility: "Visibilidade dos usuários seguidos" +followersVisibility: "Visibilidade dos seguidores" continueThread: "Ver mais desta conversa" deleteAccountConfirm: "Deseja realmente excluir a conta?" incorrectPassword: "Senha inválida." @@ -925,21 +974,33 @@ fast: "Rápido" sensitiveMediaDetection: "Detecção de conteúdo sensível" localOnly: "Apenas local" remoteOnly: "Apenas remoto" +failedToUpload: "Falha ao enviar" +cannotUploadBecauseInappropriate: "Esse arquivo não pôde ser enviado porque partes dele foram detectadas como potencialmente inapropriadas." +cannotUploadBecauseNoFreeSpace: "Envio falhou devido à falta de capacidade no Drive." cannotUploadBecauseExceedsFileSizeLimit: "Não é possível realizar o upload deste arquivo porque ele excede o tamanho máximo permitido." beta: "Beta" enableAutoSensitive: "Marcar automaticamente como conteúdo sensível" enableAutoSensitiveDescription: "Quando disponível, a marcação de mídia sensível será automaticamente atribuído ao conteúdo de mídia usando aprendizado de máquina. Mesmo que você desative essa função, em alguns servidores, isso pode ser configurado automaticamente." activeEmailValidationDescription: "A validação do endereço de e-mail do usuário será realizada de forma mais rigorosa, considerando se é um endereço descartável ou se é possível realizar comunicação efetiva. Se desativado, apenas a validade do formato do endereço será verificada como uma sequência de caracteres." +navbar: "Barra de navegação" shuffle: "Aleatório" account: "Contas" move: "Mover" pushNotification: "Notificações Push" subscribePushNotification: "Ativar notificações push" unsubscribePushNotification: "Desativar notificações push" +pushNotificationAlreadySubscribed: "Notificações push já estão habilitadas" +pushNotificationNotSupported: "Seu navegador ou instância não tem suporte às notificações push" +sendPushNotificationReadMessage: "Apagar notificações push quando elas foram lidas" +sendPushNotificationReadMessageCaption: "Pode aumentar o consumo de energia do dispositivo." +windowMaximize: "Maximizar" windowMinimize: "Minimizar" windowRestore: "Restaurar" caption: "legenda" +loggedInAsBot: "Atualmente conectado como bot" tools: "Ferramentas" +cannotLoad: "Não foi possível carregar" +numberOfProfileView: "Visualizações do perfil" like: "Curtir" unlike: "Remover curtida" numberOfLikes: "Número de curtidas" @@ -948,6 +1009,7 @@ neverShow: "Não exibir novamente" remindMeLater: "Lembrar mais tarde" didYouLikeMisskey: "Você gostou do Misskey?" pleaseDonate: "O Misskey é um software gratuito utilizado por {host}. Para que possamos continuar o desenvolvimento, pedimos que considerem fazer doações. A sua contribuição é muito importante!" +correspondingSourceIsAvailable: "O código-fonte correspondente está disponível em {anchor}" roles: "Cargos" role: "Cargo" noRole: "Nenhum cargo" @@ -957,6 +1019,7 @@ assign: "Atribuir" unassign: "Remover" color: "Cor" manageCustomEmojis: "Gerenciar Emojis customizados" +manageAvatarDecorations: "Gerenciar decorações de avatar" youCannotCreateAnymore: "Você atingiu o limite de criação." cannotPerformTemporary: "Ação temporariamente indisponível" cannotPerformTemporaryDescription: "Esta ação não pôde ser concluída devido ao excesso de pedidos em sucessão. Tente novamente em alguns momentos." @@ -974,218 +1037,635 @@ thisPostMayBeAnnoyingHome: "Postar na linha do tempo inicial" thisPostMayBeAnnoyingCancel: "Cancelar" thisPostMayBeAnnoyingIgnore: "Postar mesmo assim" collapseRenotes: "Ocultar repostagens já visualizadas" +collapseRenotesDescription: "Colapsar notas em que você reagiu ou repostou." internalServerError: "Erro interno de servidor" +internalServerErrorDescription: "Houve um erro inesperado no servidor." +copyErrorInfo: "Copiar detalhes de erro" +joinThisServer: "Cadastrar-se na instância" +exploreOtherServers: "Buscar outra instância" +letsLookAtTimeline: "Dar uma olhada na linha do tempo" +disableFederationConfirm: "Realmente desabilitar a federação?" +disableFederationConfirmWarn: "Mesmo se defederado, publicações continuarão sendo públicas, a menos que seja definido o contrário. Você geralmente não precisa disso." +disableFederationOk: "Desabilitar" +invitationRequiredToRegister: "Essa instância é apenas para convidados. Você precisa inserir um código válido para se cadastrar." emailNotSupported: "O envio de e-mails não é suportado nesta instância" +postToTheChannel: "Publicar ao canal" +cannotBeChangedLater: "Isso não pode ser alterado." +reactionAcceptance: "Aceitação de Reações" likeOnly: "Apenas curtidas" likeOnlyForRemote: "Tudo (somente curtidas remotas)" +nonSensitiveOnly: "Apenas não-sensível" nonSensitiveOnlyForLocalLikeOnlyForRemote: "Apenas não sensíveis (somente curtidas remotas)" rolesAssignedToMe: "Cargos atribuídos a mim" +resetPasswordConfirm: "Deseja realmente mudar a sua senha?" +sensitiveWords: "Palavras sensíveis" +sensitiveWordsDescription: "A visibilidade de todas as notas contendo as palavras configuradas será colocadas como \"Início\" automaticamente. Você pode listar várias delas separando-as por linha." +sensitiveWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" +prohibitedWords: "Palavras proibídas" +prohibitedWordsDescription: "Habilita um erro ao tentar publicar uma nota contendo as palavras escolhidas. Várias palavras podem ser escolhidas, separando-as por linha." +prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" +hiddenTags: "Hashtags escondidas" +hiddenTagsDescription: "Selecione tags que não serão exibidas na lista de destaques. Várias tags podem ser escolhidas, separadas por linha." +notesSearchNotAvailable: "A pesquisa de notas está indisponível." +license: "Licença" unfavoriteConfirm: "Deseja realmente remover dos favoritos?" +myClips: "Meus clipes" drivecleaner: "Limpeza do drive" +retryAllQueuesNow: "Tentar novamente todas as pendências" retryAllQueuesConfirmTitle: "Gostaria de tentar novamente agora?" +retryAllQueuesConfirmText: "Isso irá temporariamente aumentar a carga do servidor." +enableChartsForRemoteUser: "Gerar gráficos estatísticos de usuários remotos" +enableChartsForFederatedInstances: "Gerar gráficos estatísticos de instâncias remotas" +showClipButtonInNoteFooter: "Adicionar \"Clip\" ao menu de ação de notas" reactionsDisplaySize: "Tamanho de exibição das reações" +limitWidthOfReaction: "Limita o comprimento máximo de reações e as exibe em tamanho reduzido" +noteIdOrUrl: "ID ou URL de nota" +video: "Vídeo" +videos: "Vídeos" +audio: "Áudio" +audioFiles: "Áudio" +dataSaver: "Economia de Dados" +accountMigration: "Migração da Conta" +accountMoved: "Esse usuário moveu-se para uma nova conta:" +accountMovedShort: "Essa conta foi migrada." +operationForbidden: "Operação proibída" +forceShowAds: "Sempre mostrar propagandas" +addMemo: "Adicionar memorando" +editMemo: "Editar memorando" reactionsList: "Reações" renotesList: "Repostagens" +notificationDisplay: "Notificações" leftTop: "Superior esquerdo" rightTop: "Superior direito" leftBottom: "Inferior esquerdo" rightBottom: "Inferior direito" +stackAxis: "Eixo de empilhamento" vertical: "Vertical" horizontal: "Exibir painel lateral inteiro" position: "Posição" serverRules: "Regras do servidor" +pleaseConfirmBelowBeforeSignup: "Para cadastrar-se no servidor, você precisa ler e concordar como seguinte:" +pleaseAgreeAllToContinue: "Você precisa concordar com todos os campos acima para continuar." continue: "Continuar" +preservedUsernames: "Nomes de usuário reservados" preservedUsernamesDescription: "Liste os nomes de usuário que deseja reservar, separando-os por quebras de linha. Os nomes de usuário especificados aqui não poderão ser utilizados durante a criação de contas. No entanto, esta restrição não se aplica quando a conta é criada por um administrador. Além disso, as contas que já existem não serão afetadas." +createNoteFromTheFile: "Compor nota a partir desse arquivo" archive: "Arquivo" +archived: "Arquivado" +unarchive: "Desarquivar" channelArchiveConfirmTitle: "Deseja realmente arquivar {name}?" +channelArchiveConfirmDescription: "Um canal arquivado não irá aparecer na lista de canais e nem resultados de pesquisa. Novas publicações não poderão mais ser adicionadas." +thisChannelArchived: "Esse canal foi arquivado." +displayOfNote: "Exibição de nota" +initialAccountSetting: "Configuração inicial do perfil" youFollowing: "Seguindo" +preventAiLearning: "Rejeitar uso de Aprendizado de Máquina (IA Generativa)" preventAiLearningDescription: "Solicita-se que o conteúdo de notas e imagens enviadas não seja usado como objeto de aprendizado por sistemas externos de geração de texto ou imagens. Isso é alcançado incluindo a flag 'noai' na resposta HTML. No entanto, o cumprimento dessa solicitação depende do próprio sistema de IA, portanto, não é garantia total de prevenção de aprendizado." options: "Opções" +specifyUser: "Usuário específico" +lookupConfirm: "Deseja buscar?" +openTagPageConfirm: "Deseja abrir a uma página de hashtag?" +specifyHost: "Especificar um hospedeiro" +failedToPreviewUrl: "Não foi possível carregar prévia" +update: "Atualizar" rolesThatCanBeUsedThisEmojiAsReaction: "Cargos que podem utilizar este emoji como reação" rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Se nenhum cargo for especificado, qualquer pessoa pode usar este emoji como reação." rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Estes cargos devem ser públicos." +cancelReactionConfirm: "Realmente excluir a sua reação?" +changeReactionConfirm: "Realmente mudar a sua reação?" +later: "Talvez mais tarde" +goToMisskey: "Ao Misskey" +additionalEmojiDictionary: "Dicionários adicionais de emoji" +installed: "Instalado" +branding: "Marca" +enableServerMachineStats: "Publicar estatísticas do hardware do servidor" +enableIdenticonGeneration: "Habilitar geração de identicon de usuário" +turnOffToImprovePerformance: "Desligar isso pode melhorar o desempenho." +createInviteCode: "Gerar convite" +createWithOptions: "Criar com opções" +createCount: "Número de convites" +inviteCodeCreated: "Convite gerado" +inviteLimitExceeded: "Você excedeu o limite de convites que podem ser gerados." +createLimitRemaining: "Limite de convites: {limit}" +inviteLimitResetCycle: "Esse limite irá tornar-se {limit} em {time}." +expirationDate: "Data de expiração" +noExpirationDate: "Sem expiração" +inviteCodeUsedAt: "Código de convite usado em" +registeredUserUsingInviteCode: "Convite usado por" waitingForMailAuth: "Verificação de e-mail pendente " +inviteCodeCreator: "Convite criado por" +usedAt: "Usado em" +unused: "Não foi usado" +used: "Usado" +expired: "Expirado" +doYouAgree: "Concorda?" +beSureToReadThisAsItIsImportant: "Por favor, leia essa informação importante." +iHaveReadXCarefullyAndAgree: "Eu li o texto \"{x}\" e concordo." +dialog: "Diálogo" icon: "Avatar" +forYou: "Para você" +currentAnnouncements: "Anúncios atuais" +pastAnnouncements: "Anúncios passados" +youHaveUnreadAnnouncements: "Há anúncios não lidos." +useSecurityKey: "Por favor, siga as instruções do seu navegador ou dispositivo para utilizar uma chave de acesso." replies: "Responder" renotes: "Repostar" +loadReplies: "Mostrar respostas" +loadConversation: "Mostrar conversa" +pinnedList: "Lista fixada" keepScreenOn: "Manter a tela do dispositivo sempre ligada" +verifiedLink: "A autoria do link foi verificada" +notifyNotes: "Notificar sobre novas notas" +unnotifyNotes: "Deixar de notificar sobre novas notas" +authentication: "Autenticação" +authenticationRequiredToContinue: "Por favor, autentique-se para continuar" +dateAndTime: "Data e Hora" +showRenotes: "Exibir reposts" +edited: "Editado" +notificationRecieveConfig: "Configurações de Notificação" +mutualFollow: "Seguidor mútuo" +followingOrFollower: "Seguidor ou usuário seguido" +fileAttachedOnly: "Apenas notas com arquivos" +showRepliesToOthersInTimeline: "Mostrar respostas aos outros na linha do tempo" +hideRepliesToOthersInTimeline: "Esconder respostas dos outros na linha do tempo" +showRepliesToOthersInTimelineAll: "Mostrar respostas aos outros, mas apenas de quem você segue, na linha do tempo" +hideRepliesToOthersInTimelineAll: "Esconder respostas de todos que você segue na linha do tempo" +confirmShowRepliesAll: "Essa operação é irreversível. Você gostaria de mostrar respostas a todos que você segue na sua linha do tempo?" +confirmHideRepliesAll: "Essa operação é irreversível. Você gostaria de esconder respostas a todos que você segue na sua linha do tempo?" +externalServices: "Serviços Externos" +sourceCode: "Código-fonte" +sourceCodeIsNotYetProvided: "Código-fonte está indisponível. Contate o administrador para resolver esse problema." +repositoryUrl: "URL do repositório" +repositoryUrlDescription: "Se você estiver utilizando Misskey como está (sem mudanças no código-fonte), insira https://github.com/misskey-dev/misskey" +repositoryUrlOrTarballRequired: "Se você não publicou um repositório, você precisa providenciar uma tarball em seu lugar. Veja .config/example.yml para mais informações." +feedback: "Feedback" +feedbackUrl: "Link para Feedback" +impressum: "Impressum" +impressumUrl: "URL de 'Impressum'" +impressumDescription: "Em alguns países, como a Alemanha, a inclusão de informação de contato do operador de um serviço é legalmente exigida para websites comerciais." +privacyPolicy: "Política de Privacidade" +privacyPolicyUrl: "URL da Política de Privacidade" +tosAndPrivacyPolicy: "Termos de Serviço e Política de Privacidade" +avatarDecorations: "Decorações de avatar" +attach: "Anexar" +detach: "Remover" +detachAll: "Remover Tudo" +angle: "Ângulo" flip: "Inversão" +showAvatarDecorations: "Exibir decorações de avatar" +releaseToRefresh: "Solte para atualizar" +refreshing: "Atualizando..." +pullDownToRefresh: "Puxe para baixo para atualizar" +disableStreamingTimeline: "Desabilitar atualizações em tempo real da linha do tempo" +useGroupedNotifications: "Agrupar notificações" +signupPendingError: "Houve um problema ao verificar o endereço de email. O link pode ter expirado." +cwNotationRequired: "Se \"Esconder conteúdo\" está habilitado, uma descrição deve ser adicionada." +doReaction: "Adicionar reação" +code: "Código" +reloadRequiredToApplySettings: "É necessário reiniciar para aplicar as configurações." +remainingN: "Restante: {n}" +overwriteContentConfirm: "Você tem certeza de que deseja sobrescrever o conteúdo atual?" +seasonalScreenEffect: "Efeito de Tela Sazonal" +decorate: "Decorar" +addMfmFunction: "Adicionar MFM" +enableQuickAddMfmFunction: "Exibir seleção avançada de MFM" +bubbleGame: "Bubble Game" +sfx: "Efeitos Sonoros" +soundWillBePlayed: "Sons serão reproduzidos" +showReplay: "Ver Replay" +replay: "Replay" +replaying: "Mostrando Replay" +endReplay: "Sair do Replay" +copyReplayData: "Copiar dados de Replay" +ranking: "Ranking" lastNDays: "Últimos {n} dias" +backToTitle: "Voltar à página inicial" +hemisphere: "Onde você se localiza" +withSensitive: "Incluir notas com arquivos sensíveis" +userSaysSomethingSensitive: "Publicação de {name} contém conteúdo sensível" +enableHorizontalSwipe: "Arraste para mudar de aba" +loading: "Carregando" surrender: "Cancelar" +gameRetry: "Tentar Novamente" +notUsePleaseLeaveBlank: "Deixe em branco caso inutilizado" +useTotp: "Digite a senha de uso único" +useBackupCode: "Usar códigos de “backup”" +launchApp: "Iniciar aplicação" +useNativeUIForVideoAudioPlayer: "Utilizar UI do navegador ao reproduzir vídeo e áudio" +keepOriginalFilename: "Manter nome original do arquivo" +keepOriginalFilenameDescription: "Se você desabilitar essa opção, os nomes de arquivos serão substituídos por uma sequência aleatória ao enviar arquivos." +noDescription: "Não há descrição" +alwaysConfirmFollow: "Sempre confirmar ao seguir" +inquiry: "Contato" +tryAgain: "Por favor, tente novamente mais tarde" +confirmWhenRevealingSensitiveMedia: "Confirmar ao revelar mídia sensível" +sensitiveMediaRevealConfirm: "Essa mídia pode ser sensível. Deseja revelá-la?" +createdLists: "Listas criadas" +createdAntennas: "Antenas criadas" +clipNoteLimitExceeded: "Não é possível adicionar mais notas ao clipe." _delivery: + status: "Estado de entrega" stop: "Suspenso" + resume: "Continuar entrega" _type: none: "Publicando" + manuallySuspended: "Suspenso manualmente" + goneSuspended: "Servidor foi suspenso devido ao seu apagamento" + autoSuspendedForNotResponding: "Servidor foi suspenso por não responder" +_bubbleGame: + howToPlay: "Como jogar" + hold: "Próximos" + _score: + score: "Pontuação" + scoreYen: "Dinheiro recebido" + highScore: "Melhor pontuação" + maxChain: "Número máximo de encadeamentos" + yen: "{yen} Yen" + estimatedQty: "{qty} Peças" + scoreSweets: "{onigiriQtyWithUnit} Onigiri" + _howToPlay: + section1: "Ajuste a posição e solte o objeto na caixa." + section2: "Quando dois objetos do mesmo tipo tocam-se, eles tornam-se outro objeto e você ganha pontos." + section3: "O jogo acaba quando objetos transbordam da caixa. Busque uma pontuação alta ao fundir objetos enquanto evita transbordar a caixa." +_announcement: + forExistingUsers: "Apenas aos usuários existente" + forExistingUsersDescription: "Se habilitado, esse anúncio será exibido apenas para usuários existentes no tempo de publicação. Se desabilitado, novos usuários também o receberão. " + needConfirmationToRead: "Exigir confirmação de leitura" + needConfirmationToReadDescription: "Um lembrete adicional será exibido para confirmar a leitura do anúncio. Esse anúncio também será excluído de qualquer forma de \"Marcar tudo como lido\"." + end: "Arquivar anúncio" + tooManyActiveAnnouncementDescription: "O excesso de anúncios pode atrapalhar a experiência do usuário. Considere arquivar anúncios obsoletos." + readConfirmTitle: "Marcar como lido?" + readConfirmText: "Isso marcará o conteúdo de \"{title}\" como lido." + shouldNotBeUsedToPresentPermanentInfo: "É preferível utilizar anúncios para publicar informações atuais e de curto prazo, e não informações que serão relevantes por muito tempo." + dialogAnnouncementUxWarn: "O uso de duas ou mais notificações de diálogo simultaneamente pode impactar significativamente a experiência de usuário. Portanto, utilize-as cuidadosamente." + silence: "Sem notificação" + silenceDescription: "Habilitar isso irá pular a notificação desse anúncio e o usuário não precisará lê-lo." _initialAccountSetting: + accountCreated: "A sua conta foi criada com sucesso!" + letsStartAccountSetup: "Em primeiro lugar, vamos configurar o seu perfil." + letsFillYourProfile: "Primeiramente, vamos configurar o seu perfil." + profileSetting: "Configurações do perfil" + privacySetting: "Configurações de privacidade" + theseSettingsCanEditLater: "Você pode alterar estas configurações mais tarde." + youCanEditMoreSettingsInSettingsPageLater: "Há mais configurações na página \"Configurações\". Não se esqueça de visitá-la mais tarde." followUsers: "Siga usuários que lhe interessam para criar a sua linha do tempo." + pushNotificationDescription: "Habilitar notificações push o possibilitará receber notificações de {name} diretamente no seu dispositivo." + initialAccountSettingCompleted: "Configuração de perfil completa!" + haveFun: "Aproveite {name}!" + youCanContinueTutorial: "Você pode iniciar um tutorial de como utilizar {name} (Misskey) ou pode sair da configuração e começar o uso imediatamente." + startTutorial: "Iniciar Tutorial" + skipAreYouSure: "Deseja pular a configuração de perfil?" + laterAreYouSure: "Deseja adiar a configuração de perfil?" +_initialTutorial: + launchTutorial: "Iniciar Tutorial" + title: "Tutorial" + wellDone: "Ótimo!" + skipAreYouSure: "Sair do Tutorial?" + _landing: + title: "Bem-vindo ao Tutorial!" + description: "Aqui, você pode aprender o básico de como usar o Misskey e as suas funções." + _note: + title: "O que é uma Nota?" + description: "Publicações no Misskey chamam-se 'Notas'. Notas são organizadas cronologicamente na linha do tempo e atualizam em tempo real." + reply: "Clique nesse botão para responder a uma mensagem. Também é possível responder respostas, continuando a conversa como uma \"thread\"." + renote: "Você pode compartilhar essa nota na sua linha do tempo. Você também pode citá-la com os seus comentários." + reaction: "Você pode adicionar reações à nota. Mais detalhes serão explicados na próxima página." + menu: "Você pode ver detalhes da nota, copiar links e realizar outras ações." + _reaction: + title: "O que são Reações?" + description: "É possível reagir às notas com diversos emojis. Reações permitem que você expresse sutilezas que não são possíveis apenas com uma curtida." + letsTryReacting: "Reações podem ser adicionadas clicando no botão \"+\". Tente reagir à nota de exemplo." + reactToContinue: "Adicione uma reação para continuar." + reactNotification: "Você receberá notificações em tempo real quando alguém reagir à sua nota." + reactDone: "Você pode desfazer uma reação ao selecionar o botão \"-\"." + _timeline: + title: "O Conceito das Linhas do Tempo" + description1: "Misskey providencia diversas linhas do tempo baseadas na sua utilidade (algumas podem não estar disponíveis a partir das configurações da instância)." + home: "Você pode ver as notas das contas seguidas. " + local: "Você pode ver notas de todos os usuários dessa instância." + social: "Notas da linha do tempo Início e Local serão exibidas." + global: "Você pode ver notas de todos os servidores conectados." + description2: "Você pode alterar dentre as linhas do tempo no todo da tela a qualquer momento." + description3: "Adicionalmente, há \"listas\" e \"canais\". Para mais informações, acesse {link}." + _postNote: + title: "Opções de Postagem de Nota" + description1: "Ao postar uma nota no Misskey, diversas opções estão disponíveis. A ficha de publicação parece com isto: " + _visibility: + description: "Você pode limitar quem vê a sua nota." + public: "Sua nota será visível a todos os usuários." + home: "Publicar apenas na linha do tempo Início. Pessoas visitando seu perfil, seja seguindo ou por um repost poderão vê-los." + followers: "Visível apenas para seguidores. Apenas seguidores podem vê-la e mais ninguém, e ela não pode ser repostada pelos demais." + direct: "Visível apenas para usuários específicos, e o destinatário será notificado. Pode ser usado como uma alternativa às mensagens diretas." + doNotSendConfidencialOnDirect1: "Tenha cuidado ao enviar informações sensíveis!" + doNotSendConfidencialOnDirect2: "Administradores do servidor podem ver o que foi escrito. Cuidado, também, ao enviar notas diretas a usuários de servidores não confiáveis." + localOnly: "Publicar com essa opção não federará a nota com outros servidores. Usuários desses servidores não poderão ver essas notas diretamente, independente das opções de visibilidade acima. " + _cw: + title: "Aviso de Conteúdo" + description: "Ao invés do corpo do texto, o conteúdo escrito na caixa \"anotação\" será exibido. Apertar \"Carregar mais\" irá revelar o corpo." + _exampleNote: + cw: "Isso irá te esfomear!" + note: "Acabei de comer um donut coberto de chocolate! 🍩😋" + useCases: "Isso pode ser usado caso seja exigido, pelas diretrizes do servidor, o cuidado com algum tópico ou ao publicar conteúdo sensível ou spoilers." + _howToMakeAttachmentsSensitive: + title: "Como Marcar Anexos como Sensíveis?" + description: "Para anexos cujo conteúdo é considerado sensível pelas diretrizes do servidor ou quando pretende-se esconder o seu conteúdo, adicione o sinal \"sensível\"." + tryThisFile: "Tente marcar a imagem anexada como sensível!" + _exampleNote: + note: "Opa, me atrapalhei abrindo a tampa do natô..." + method: "Para marcar um anexo como sensível, clique na sua miniatura, abra o menu e clique \"Marcar como sensível\"." + sensitiveSucceeded: "Ao anexar arquivos, por favor atribua uma sensibilidade coerente com as diretrizes da instância." + doItToContinue: "Marque o anexo como sensível para prosseguir." + _done: + title: "Você completou o tutorial! 🎉" + description: "As funções apresentadas aqui são apenas uma pequena parte. Para um conhecimento mais detalhado do uso do Misskey, acesse {link}." +_timelineDescription: + home: "Na linha do tempo Início, você verá notas dos usuários que você segue." + local: "Na linha do tempo Local, você verá notas de todos os usuários da instância." + social: "Na linha do tempo Social, você verá notas do Início e Local." + global: "Na linha do tempo Global, você verá notas de todas as instâncias conectadas." +_serverRules: + description: "Um grupo de regras a ser exibido antes de um cadastro. É recomendado que se faça um resumo dos Termos de Serviço." _serverSettings: iconUrl: "URL do ícone" + appIconDescription: "Especifica o ícone utilizado quando {host} é exibido como um app." + appIconUsageExample: "Exemplo: Como PWA, ou quando exibido num marcador de páginas ou na tela inicial de um celular" + appIconStyleRecommendation: "Como o ícone pode ser cortado para um quadrado ou círculo, é recomendado adicionar um fundo colorido na imagem." + appIconResolutionMustBe: "A resolução mínima é {resolution}." + manifestJsonOverride: "Sobrescrever manifest.json" + shortName: "Abreviação" + shortNameDescription: "Uma abreviação do nome da instância que pode ser exibido caso o nome oficial completo seja muito longo." + fanoutTimelineDescription: "Melhora significativamente a performance do retorno da linha do tempo e reduz o impacto no banco de dados quando habilitado. Em contrapartida, o uso de memória do Redis aumentará. Considere desabilitar em casos de baixa disponibilidade de memória ou instabilidade do servidor." + fanoutTimelineDbFallback: "\"Fallback\" ao banco de dados" + fanoutTimelineDbFallbackDescription: "Quando habilitado, a linha do tempo irá recuar ao banco de dados caso consultas adicionais sejam feitas e ela não estiver em cache. Quando desabilitado, o impacto no servidor será reduzido ao eliminar o recuo, mas limita a quantidade de linhas do tempo que podem ser recebidas." + inquiryUrl: "URL de inquérito" + inquiryUrlDescription: "Especifique um URL para um formulário de inquérito para a administração ou uma página web com informações de contato." _accountMigration: + moveFrom: "Migrar outra conta para essa" + moveFromSub: "Criar um 'alias' a outra conta" + moveFromLabel: "Conta original #{n}" moveFromDescription: "Se você deseja migrar de outra conta para esta, é necessário criar um alias aqui. Por favor, insira a conta de origem da migração no seguinte formato: @username@server.example.com. Para excluir o alias, deixe o campo em branco e clique em salvar (não recomendado)." + moveTo: "Migrar dessa conta para outra" + moveToLabel: "Conta para a qual se mover:" + moveCannotBeUndone: "A migração de conta não pode ser desfeita." moveAccountDescription: "Você está migrando para uma nova conta.\n ・Seus seguidores irão automaticamente seguir a nova conta.\n ・Todas as suas conexões de seguidores nesta conta serão removidas.\n ・Você não poderá mais criar novas notas nesta conta.\n\nA migração dos seguidores é automática, mas a migração das pessoas que você segue deve ser feita manualmente. Antes de migrar, exporte quem você está seguindo nesta conta e, assim que migrar, importe essa lista na nova conta.\nO mesmo se aplica para listas, silenciamentos e bloqueios, que também devem ser migrados manualmente.\n\n(Esta descrição se refere ao comportamento do servidor Misskey v13.12.0 ou posterior. Outros softwares ActivityPub, como Mastodon, podem ter comportamentos diferentes.)" moveAccountHowTo: "Para realizar a migração da conta, primeiro crie um alias para esta conta no destino da migração. Após criar o alias, insira a conta de destino da migração no seguinte formato: @username@server.example.com." + startMigration: "Migrar" migrationConfirm: "Tem certeza de que deseja migrar esta conta para '{account}'? Uma vez migrada, não poderá ser desfeita e não será possível usar esta conta novamente em seu estado original." + movedAndCannotBeUndone: "Essa conta foi migrada. A migração não pode ser desfeita." postMigrationNote: "A remoção dos seguidores desta conta será realizada 24 horas após a operação de migração. O número de seguidores e seguidos desta conta se tornará zero. Os seguidores não serão removidos, portanto, eles continuarão a ver as postagens destinadas aos seguidores desta conta." + movedTo: "Conta para a qual se mover:" _achievements: earnedAt: "Data de aquisição" _types: _notes1: title: "Configurando o meu misskey" - description: "Postou uma nota pela primeira vez" + description: "Post uma nota pela primeira vez" flavor: "Divirta-se com o Misskey!" _notes10: title: "Algumas notas" - description: "Postou 10 notas" + description: "Poste 10 notas" _notes100: title: "Um monte de notas" - description: "Postou 100 notas" + description: "Poste 100 notas" _notes500: title: "Coberto por notas" - description: "Postou 500 notas" + description: "Poste 500 notas" _notes1000: title: "Uma montanha de notas" - description: "Postou 1000 notas" + description: "Poste 1 000 notas" _notes5000: title: "Enxurrada de notas" - description: "Postou 5000 notas" + description: "Poste 5000 notas" _notes10000: - title: "Super nota" - description: "Postou 10000 notas" + title: "Supernota" + description: "Poste 10 000 notas" _notes20000: title: "Preciso... de mais... notas..." - description: "Postou 20000 notas" + description: "Poste 20 000 notas" _notes30000: title: "Notas, Notas, NOTAS!" - description: "Postou 30000 notas" + description: "Poste 30 000 notas" _notes40000: title: "Fábrica de notas" - description: "Postou 40000 notas" + description: "Poste 40 000 notas" _notes50000: title: "Planeta de notas" - description: "Postou 50000 notas" + description: "Poste 50 000 notas" _notes60000: title: "Quasar de notas" - description: "Postou 60000 notas" + description: "Poste 60 000 notas" _notes70000: title: "Buraco negro de notas" - description: "Postou 70000 notas" + description: "Poste 70 000 notas" _notes80000: title: "Galáxia de notas" - description: "Postou 80000 notas" + description: "Poste 80 000 notas" _notes90000: title: "Universo de notas" - description: "Postou 90000 notas" + description: "Poste 90 000 notas" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" - description: "Postou 100000 notas" + description: "Poste 100 000 notas" flavor: "Você realmente tem muita coisa para escrever" _login3: title: "Iniciante I" - description: "Fez login por um total de 3 dias" + description: "Faça login por um total de 3 dias" flavor: "De hoje em diante, me chame apenas de Misskist" _login7: title: "Iniciante II" - description: "Fez login por um total de 7 dias" + description: "Faça login por um total de 7 dias" flavor: "Pegando o jeito da coisa?" _login15: title: "Iniciante III" - description: "Fez login por um total de 15 dias" + description: "Faça login por um total de 15 dias" _login30: title: "Misskist I" - description: "Fez login por um total de 30 dias" + description: "Faça login por um total de 30 dias" _login60: title: "Misskist II" - description: "Fez login por um total de 60 dias" + description: "Faça login por um total de 60 dias" _login100: title: "Misskist III" - description: "Fez login por um total de 100 dias" + description: "Faça login por um total de 100 dias" flavor: "Misskist violento" _login200: title: "Freguês I" - description: "Fez login por um total de 200 dias" + description: "Faça login por um total de 200 dias" _login300: title: "Freguês II" - description: "Fez login por um total de 300 dias" + description: "Faça login por um total de 300 dias" _login400: title: "Freguês III" - description: "Fez login por um total de 400 dias" + description: "Faça login por um total de 400 dias" _login500: title: "Veterano I" - description: "Fez login por um total de 500 dias" + description: "Faça login por um total de 500 dias" flavor: "Cavalheiros, tudo o que peço são notas" _login600: title: "Veterano II" - description: "Fez login por um total de 600 dias" + description: "Faça login por um total de 600 dias" _login700: title: "Veterano III" - description: "Fez login por um total de 700 dias" + description: "Faça login por um total de 700 dias" _login800: - title: "Mestre das notas I" - description: "Fez login por um total de 800 dias" + title: "Mestre das Notas I" + description: "Faça login por um total de 800 dias" _login900: - title: "Mestre das notas II" - description: "Fez login por um total de 900 dias" + title: "Mestre das Notas II" + description: "Faça login por um total de 900 dias" _login1000: - title: "Mestre das notas III" - description: "Fez login por um total de 1000 dias" + title: "Mestre das Notas III" + description: "Faça login por um total de 1 000 dias" flavor: "Obrigado por utilizar o Misskey!" _noteClipped1: - title: "Não posso deixar de adicionar ao clipe" - description: "Adicionou a um clipe a sua primeira nota" + title: "Preciso... clipar..." + description: "Adicione a um clipe a sua primeira nota" _noteFavorited1: - title: "Astrônomo amador" - description: "Adicionou uma nota aos favoritos pela primeira vez" + title: "Astrônomo Amador" + description: "Adicione uma nota aos favoritos pela primeira vez" _myNoteFavorited1: title: "Cabeça nas estrelas" - description: "Teve uma das suas notas adicionada aos favoritos de alguém" + description: "Tenha uma das suas notas adicionada aos favoritos de alguém" _profileFilled: - title: "Tudo pronto" - description: "Configurou o seu perfil" + title: "Tudo Pronto" + description: "Configure o seu perfil" _markedAsCat: title: "Eu Sou Um Gato" - description: "Marcou a sua conta como um gato" + description: "Marque a sua conta como um gato" flavor: "Ainda não tenho um nome." _following1: title: "Primeira vez seguindo alguém" - description: "Seguiu um usuário pela primeira vez" + description: "Siga um usuário pela primeira vez" _following10: title: "Circulando, circulando" - description: "Seguiu 10 usuários" + description: "Siga 10 usuários" _following50: title: "Muitos amigos" - description: "Seguiu 50 usuários" + description: "Siga 50 usuários" _following100: - title: "100 amigos" - description: "Seguiu 100 usuários" + title: "100 Amigos" + description: "Siga 100 usuários" _following300: title: "Sobrecarga de amigos" - description: "Seguiu 300 usuários" + description: "Siga 300 usuários" _followers1: title: "Primeiro seguidor" - description: "Ganhou o seu primeiro seguidor" + description: "Ganhe o seu primeiro seguidor" _followers10: title: "Sigam-me os bons!" - description: "Ganhou 10 seguidores" + description: "Ganhe 10 seguidores" _followers50: title: "Aos montes" - description: "Ganhou 50 seguidores" + description: "Ganhe 50 seguidores" _followers100: title: "Popular" - description: "Ganhou 100 seguidores" + description: "Ganhe 100 seguidores" _followers300: title: "Em fila única, por favor" - description: "Ganhou 300 seguidores" + description: "Ganhe 300 seguidores" _followers500: title: "Torre de celular" - description: "Ganhou 500 seguidores" + description: "Ganhe 500 seguidores" _followers1000: title: "Influencer" - description: "Ganhou 1000 seguidores" + description: "Ganhe 1 000 seguidores" + _collectAchievements30: + title: "Coletor de Conquistas" + description: "Ganhe 30 conquistas" + _viewAchievements3min: + title: "Curte Conquistas" + description: "Olhe para a sua lista de conquistas por pelo menos 3 minutos" + _iLoveMisskey: + title: "Eu Amo Misskey" + description: "Poste \"I ❤ #Misskey\"" + flavor: "A equipe de desenvolvimento do Misskey aprecia profundamente o seu apoio!" + _foundTreasure: + title: "Caça ao Tesouro" + description: "Você achou o tesouro escondido" + _client30min: + title: "Pausinha" + description: "Deixe o Misskey aberto por pelo menos 30 minutos" + _client60min: + title: "Sem falta" + description: "Deixe o Misskey aberto por pelo menos 60 minutos" _noteDeletedWithin1min: title: "Deixa pra lá" - description: "Excluí a postagem dentro de 1 minuto após ter publicado" + description: "Exclua a postagem dentro de 1 minuto após a ter publicado" + _postedAtLateNight: + title: "Noturno" + description: "Poste uma nota tarde da noite" + flavor: "Tá na hora de ir dormir." + _postedAt0min0sec: + title: "Relógio Falante" + description: "Poste uma nota à meia-noite em ponto" + flavor: "Tic-Tac-Tic-Tac" + _selfQuote: + title: "Autorreferência" + description: "Cite sua própria nota" + _htl20npm: + title: "Linha do Tempo Fluida" + description: "Faça a velocidade da linha do tempo exceder 20 npm (notas por minuto)" + _viewInstanceChart: + title: "Analista" + description: "Veja os infográficos da instância" + _outputHelloWorldOnScratchpad: + title: "Olá, Mundo!" + description: "Produza \"hello world\" no Scratchpad" + _open3windows: + title: "Múlti-Janelas" + description: "Tenha ao mínimo 3 janelas abertas simultaneamente." _driveFolderCircularReference: title: "Referência circular" + description: "Tente criar uma pasta recursiva no Drive." + _reactWithoutRead: + title: "Você leu tudo isso?" + description: "Reaja a uma nota com mais de 100 caracteres dentro de 3 segundos após a sua publicação." + _clickedClickHere: + title: "Clique aqui" + description: "Você clicou aqui" + _justPlainLucky: + title: "Pura Sorte" + description: "Tem uma chance de ser obtido com uma probabilidade de 0.005% a cada 10 segundos." + _setNameToSyuilo: + title: "Complexo de Deus" + description: "Colocar seu nome como \"syuilo\"" + _passedSinceAccountCreated1: + title: "Aniversário de Um Ano" + description: "Um ano passou-se desde a criação da conta" + _passedSinceAccountCreated2: + title: "Aniversário de Dois Anos" + description: "Dois anos passaram-se desde a criação da conta" + _passedSinceAccountCreated3: + title: "Aniversário de Três Anos" + description: "Três anos passaram-se desde a criação da conta" + _loggedInOnBirthday: + title: "Feliz Aniversário" + description: "Entre no dia do seu aniversário" + _loggedInOnNewYearsDay: + title: "Feliz Ano Novo!" + description: "Entre no primeiro dia do ano" + flavor: "Para outro ótimo ano nessa instância" + _cookieClicked: + title: "Um jogo onde você clica em cookies" + description: "Clicou o cookie" + flavor: "Pera, você tá no website correto?" + _brainDiver: + title: "Brain Diver" + description: "Poste o link do Brain Diver" + flavor: "Misskey-Misskey La-Tu-Ma" + _smashTestNotificationButton: + title: "Teste de Transbordamento" + description: "Ative o teste de notificações repetidamente dentro de um curto período de tempo" + _tutorialCompleted: + title: "Diploma de Ensino Fundamental Misskey" + description: "Complete o tutorial" + _bubbleGameExplodingHead: + title: "🤯" + description: "O maior objeto no Bubble Game" + _bubbleGameDoubleExplodingHead: + title: "🤯 Duplo" + description: "Dois dos maiores objetos do Bubble Game ao mesmo tempo." + flavor: "Dá para encher uma lancheira com esses 🤯🤯." _role: new: "Novo cargo" edit: "Editar cargo" @@ -1195,8 +1675,10 @@ _role: descriptionOfPermission: "Moderador permite que você execute operações básicas relacionadas à moderação.\nAdministradores podem alterar todas as configurações do servidor." assignTarget: "Atribuir" descriptionOfAssignTarget: "Manual para gerenciar manualmente quem está incluído neste cargo.\nCondicional define uma condição e os usuários que corresponderem a ela serão incluídos automaticamente." - manual: "Documentação" + manual: "Manual" + manualRoles: "Cargos manuais" conditional: "Condicional" + conditionalRoles: "Cargos condicionais" condition: "Condição" isConditionalRole: "Este é um cargo condicional." isPublic: "Cargo público" @@ -1224,13 +1706,16 @@ _role: gtlAvailable: "Visualizar Linha do Tempo Global" ltlAvailable: "Visualizar Linha do Tempo Local" canPublicNote: "Permitir postagem pública" + mentionMax: "Número máximo de menções em uma nota" canInvite: "Permitir a criação de códigos de convites para a instância" inviteLimit: "Limite de códigos de convite" inviteLimitCycle: "Intervalo de emissão do código de convite" inviteExpirationTime: "Prazo de validade do código de convite" canManageCustomEmojis: "Permitir gerenciar emojis personalizados" + canManageAvatarDecorations: "Gerenciar decorações de avatar" driveCapacity: "Capacidade do drive" alwaysMarkNsfw: "Sempre marcar arquivos como NSFW" + canUpdateBioMedia: "Permitir a edição de ícone ou imagem do banner." pinMax: "Número máximo de notas fixadas" antennaMax: "Número máximo de antenas" wordMuteMax: "Número máximo de caracteres nas palavras silenciadas" @@ -1243,9 +1728,17 @@ _role: descriptionOfRateLimitFactor: "Valores menores são menos restritivos, valores maiores são mais restritivos." canHideAds: "Permitir ocultar anúncios" canSearchNotes: "Permitir a busca de notas" + canUseTranslator: "Uso do tradutor" + avatarDecorationLimit: "Número máximo de decorações de avatar que podem ser aplicadas" _condition: + roleAssignedTo: "Atribuído a cargos manuais" isLocal: "Usuário local" isRemote: "Usuário remoto" + isCat: "Usuários Gatinho" + isBot: "Usuários Bot" + isSuspended: "Usuário suspenso" + isLocked: "Contas privadas" + isExplorable: "Encontrável em \"Explorar\"" createdLessThan: "Menos de X passados desde a criação da conta" createdMoreThan: "Mais de X passados desde a criação da conta" followersLessThanOrEq: "Possui X ou menos seguidores" @@ -1259,13 +1752,19 @@ _role: not: "Não ~ (Condicional)" _sensitiveMediaDetection: description: "Use o aprendizado de máquina para detectar automaticamente mídias sensíveis para moderação. Isso pode aumentar ligeiramente a carga no servidor." + sensitivity: "Detecção de sensibilidade" sensitivityDescription: "Ao reduzir a sensibilidade, as detecções incorretas (falsos positivos) diminuem. Ao aumentar a sensibilidade, as falhas de detecção (falsos negativos) diminuem." + setSensitiveFlagAutomatically: "Marcar como sensível" + setSensitiveFlagAutomaticallyDescription: "Os resultados da detecção interna serão mantidos mesmo se essa opção estiver desligada." + analyzeVideos: "Habilitar análise de vídeos" + analyzeVideosDescription: "Analisa vídeos em adição a imagens. Isso irá aumentar levemente a carga do servidor." _emailUnavailable: used: "O endereço de e-mail informado já está sendo utilizado" format: "Formado de e-mail inválido" disposable: "Endereços de e-mail descartáveis não devem ser utilizados" mx: "O servidor de informado é inválido" smtp: "O servidor de e-mail não está respondendo" + banned: "Você não pode se cadastrar com esse endereço de email" _ffVisibility: public: "Público" followers: "Visível apenas para seguidores" @@ -1285,10 +1784,17 @@ _ad: back: "Voltar" reduceFrequencyOfThisAd: "Diminuir frequência deste anúncio" hide: "Não exibir anúncios" + timezoneinfo: "O dia da semana é determinado pelo fuso horário do servidor." + adsSettings: "Configurações de propaganda" + notesPerOneAd: "Intervalo de notas entre o anúncio nas atualizações em tempo real." + setZeroToDisable: "Selecione o valor 0 para desabilitar anúncios nas atualizações em tempo real." + adsTooClose: "O intervalo atual de anúncio pode impactar negativamente a experiência de usuário por ser muito baixo." _forgotPassword: enterEmail: "Por favor, insira o endereço de e-mail usado no cadastro de sua conta. Um link para redefinição de senha será enviado para esse endereço." ifNoEmail: "Caso você não tenha registrado um endereço de e-mail, por favor, entre em contato com o administrador." + contactAdmin: "Essa instância não possui suporte ao uso de endereços de email, contate seu administrador para mudar a sua senha." _gallery: + my: "Minha Galeria" liked: "Postagens curtidas" like: "Curtir" unlike: "Remover curtida" @@ -1297,40 +1803,224 @@ _email: title: "Você tem um novo seguidor" _receiveFollowRequest: title: "Você recebeu um pedido de seguidor" +_plugin: + install: "Instalar plugins" + installWarn: "Por favor, não instale plugins duvidosos." + manage: "Gerenciar plugins" + viewSource: "Ver código-fonte" + viewLog: "Mostrar registo" _preferencesBackups: + list: "Backups criados" + saveNew: "Salvar novo backup" + loadFile: "Carregar de arquivo" + apply: "Aplicar a este dispositivo" + save: "Salvar mudanças" + inputName: "Insira um nome para esse backup" cannotSave: "Não foi possível salvar" + nameAlreadyExists: "Um backup chamado \"{name}\" já existe. Por favor, insira outro nome." applyConfirm: "Deseja aplicar o backup '{name}' ao dispositivo atual? As configurações atuais do dispositivo serão perdidas." + saveConfirm: "Salvar backup como \"{name}\"?" deleteConfirm: "Deseja excluir {name}?" + renameConfirm: "Renomear esse backup de \"{old}\" para \"{new}\"?" + noBackups: "Não há backups. Você pode configurar suas configurações de cliente nesse servidor ao selecionar \"Criar novo backup\"." + createdAt: "Criado em: {date} {time}" + updatedAt: "Atualizado em: {date} {time}" cannotLoad: "Não foi possível carregar" + invalidFile: "Formato de arquivo inválido" +_registry: + scope: "Escopo" + key: "Chave" + keys: "Chave" + domain: "Domínio" + createKey: "Criar chave" +_aboutMisskey: + about: "Misskey é um software de código aberto desenvolvido por syulio desde 2014." + contributors: "Contribuidores principais" + allContributors: "Todos os contribuidores" + source: "Código-fonte" + original: "Original" + thisIsModifiedVersion: "{name} utiliza uma versão modificada do Misskey original." + translation: "Traduza o Misskey" + donate: "Doe para o Misskey" + morePatrons: "Nós apreciamos o apoio de vários outros apoiadores não listados aqui. Obrigado! 🥰" + patrons: "Apoiadores" + projectMembers: "Membros do projeto" +_displayOfSensitiveMedia: + respect: "Esconder mídia marcada como sensível" + ignore: "Exibir mídia marcada como sensível" + force: "Esconder toda mídia" +_instanceTicker: + none: "Nunca mostrar" + remote: "Mostrar para usuários remotos" + always: "Sempre mostrar" +_serverDisconnectedBehavior: + reload: "Recarregar automaticamente" + dialog: "Exibir diálogo de aviso de conteúdo" + quiet: "Exibir aviso de conteúdo discreto" _channel: + create: "Criar canal" + edit: "Editar canal" + setBanner: "Definir banner" + removeBanner: "Remover banner" featured: "Destaques" + owned: "Autoral" following: "Seguindo" usersCount: "{n} usuários ativos" notesCount: "{n} notas" nameAndDescription: "Nome e descrição" + nameOnly: "Apenas o nome" + allowRenoteToExternal: "Permitir repostagens e citações de fora do canal" _menuDisplay: sideFull: "Exibir painel lateral inteiro" + sideIcon: "Lateral (Ícones)" top: "Exibir barra superior" hide: "Ocultar" +_wordMute: + muteWords: "Palavras silenciadas" + muteWordsDescription: "Separe com espaços para uma condicional AND (&&) ou por linha para uma condicional OR (||)." + muteWordsDescription2: "Cercar palavras-chave com barras para usar expressões regulares (RegEx)." _instanceMute: instanceMuteDescription: "Todas as notas e repostagens do servidor configurado serão silenciados, incluindo respostas aos usuários do servidor mutado." + instanceMuteDescription2: "Separar por linha" + title: "Esconder notas das instâncias listadas. " + heading: "Lista de instâncias a serem silenciadas" _theme: + explore: "Explorar Temas" + install: "Instalar um tema" + manage: "Gerenciar temas" + code: "Código do tema" description: "Descrição" + installed: "{name} foi instalado" + installedThemes: "Temas instalados" + builtinThemes: "Temas nativos" + alreadyInstalled: "Esse tema já foi instalado" + invalid: "O formato desse tema é invalido" + make: "Fazer um tema" + base: "Base" + addConstant: "Adicionar constante" + constant: "Constante" + defaultValue: "Valor padrão" + color: "Cor" + refProp: "Referenciar uma propriedade" + refConst: "Referenciar uma constante" + key: "Chave" + func: "Funções" + funcKind: "Tipo de função" + argument: "Argumento" + basedProp: "Propriedade referenciada" alpha: "Opacidade" + darken: "Escurecer" + lighten: "Esclarecer" + inputConstantName: "Insira um nome para essa constante" + importInfo: "Se você inserir o código do tema aqui, você pode importá-lo no editor de temas" deleteConstantConfirm: "Confirma a exclusão da constante {const}?" keys: + accent: "Cor de destaque" + bg: "Plano de fundo" + fg: "Texto" + focus: "Foco" + indicator: "Indicador" + panel: "Painel" + shadow: "Sombra" + header: "Cabeçalho" + navBg: "Plano de fundo da barra lateral" + navFg: "Texto da barra lateral" + navHoverFg: "Texto da coluna lateral (Selecionado)" + navActive: "Texto da coluna lateral (Ativa)" + navIndicator: "Indicador da coluna lateral" + link: "Link" + hashtag: "Hashtag" mention: "Menção" + mentionMe: "Menciona (a mim)" renote: "Repostar" + modalBg: "Plano de fundo modal" divider: "Separador" + scrollbarHandle: "Alça da barra de rolagem (Selecionada)" + scrollbarHandleHover: "Alça da barra de rolagem (Selecionada)" + dateLabelFg: "Texto do rótulo de data" + infoBg: "Plano de fundo de informações" + infoFg: "Texto de informações" + infoWarnBg: "Plano de fundo de avisos" + infoWarnFg: "Texto de avisos" + toastBg: "Plano de fundo de notificações" + toastFg: "Texto da notificação" + buttonBg: "Plano de fundo de botão" + buttonHoverBg: "Plano de fundo de botão (Selecionado)" + inputBorder: "Borda de campo digitável" + listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)" + driveFolderBg: "Plano de fundo da pasta no Drive" + wallpaperOverlay: "Sobreposição do papel de parede." + badge: "Emblema" + messageBg: "Plano de fundo do chat" + accentDarken: "Cor de destaque (Escurecida)" + accentLighten: "Cor de destaque (Esclarecida)" + fgHighlighted: "Texto Destacado" _sfx: note: "Posts" + noteMy: "Própria nota" notification: "Notificações" + reaction: "Ao selecionar uma reação" +_soundSettings: + driveFile: "Usar um arquivo de áudio do Drive." + driveFileWarn: "Selecione um arquivo de áudio do Drive." + driveFileTypeWarn: "Esse arquivo não é compatível" + driveFileTypeWarnDescription: "Selecione um arquivo de áudio" + driveFileDurationWarn: "O áudio é muito longo." + driveFileDurationWarnDescription: "Áudios longos podem atrapalhar o funcionamento do Misskey. Deseja continuar?" + driveFileError: "Não foi possível carregar o som. Por favor, altere a configuração." _ago: + future: "Futuro" + justNow: "Agora mesmo" + secondsAgo: "{n}s atrás" + minutesAgo: "{n}m atrás" + hoursAgo: "{n}h atrás" + daysAgo: "{n}d atrás" + weeksAgo: "{n} semanas atrás" + monthsAgo: "{n} meses atrás" + yearsAgo: "{n} anos atrás" invalid: "Não há nada aqui" +_timeIn: + seconds: "Em {n}s" + minutes: "Em {n}m" + hours: "Em {n}h" + days: "Em {n}d" + weeks: "Em {n} semanas" + months: "Em {n} meses" + years: "Em {n} anos" +_time: + second: "Segundo(s)" + minute: "Minuto(s)" + hour: "Hora(s)" + day: "Dia(s)" _2fa: + alreadyRegistered: "Você já cadastrou um dispositivo de autenticação de dois fatores." + registerTOTP: "Cadastrar aplicativo autenticador" + step1: "Inicialmente, instale um aplicativo autenticador (como {a} ou {b}) em seu dispositivo." + step2: "Então, escaneie o código QR exibido na tela." + step2Uri: "Acesse o seguinte URI se você estiver utilizando um aplicativo no computador" + step3Title: "Insira o código de autenticação" + step3: "Insira o código de autenticação (token) providenciado pelo seu aplicativo para terminar a configuração." + setupCompleted: "Configuração completa" + step4: "De agora em diante, quaisquer solicitações de entrada pedirão pelo código." + securityKeyNotSupported: "O seu navegador não é compatível com chaves de segurança." + registerTOTPBeforeKey: "Por favor, configure um aplicativo autenticador para registrar uma chave de segurança." securityKeyInfo: "Além da autenticação por impressão digital ou PIN, você também pode configurar a autenticação por chaves de segurança de hardware compatível com FIDO2 para proteger ainda mais a sua conta." + registerSecurityKey: "Registre um código de segurança" + securityKeyName: "Insira um nome para a chave" + tapSecurityKey: "Por favor, siga as instruções do navegador para registrar o código de segurança" + removeKey: "Remover código de segurança" removeKeyConfirm: "Deseja excluir {name}?" + whyTOTPOnlyRenew: "O autenticador não pode ser removido enquanto há códigos de segurança registrados." + renewTOTP: "Reconfigurar autenticador" + renewTOTPConfirm: "Isso interromperá o funcionamento dos códigos de aplicativos anteriores " + renewTOTPOk: "Reconfigurar" renewTOTPCancel: "Não, obrigado" + checkBackupCodesBeforeCloseThisWizard: "Antes de fechar essa janela, anote os códigos de backup a seguir." + backupCodes: "Códigos de backup" + backupCodesDescription: "Você pode utilizar esses códigos para ganhar acesso à conta caso sua autenticação de dois fatores esteja indisponível. Cada código pode ser utilizado apenas uma vez. Por favor, guarde-os em um local seguro." + backupCodeUsedWarning: "Um código de backup foi utilizado. Por favor, reconfigure a autenticação de dois fatores o quanto antes, caso não consiga utilizá-la." + backupCodesExhaustedWarning: "Todos os códigos de backup foram utilizados. Caso perca acesso à autenticação de dois fatores, você perderá o acesso à conta. Por favor, reconfigure a autenticação de dois fatores." + moreDetailedGuideHere: "Aqui está um guia detalhado" _permissions: "read:account": "Visualizar informações da conta" "write:account": "Editar informações da conta" @@ -1364,6 +2054,82 @@ _permissions: "write:gallery": "Editar sua galeria" "read:gallery-likes": "Visualizar a sua lista de curtidas da galeria" "write:gallery-likes": "Editar a sua lista de curtidas da galeria" + "read:flash": "Ver Play" + "write:flash": "Editar Plays" + "read:flash-likes": "Ver lista de Plays curtidas" + "write:flash-likes": "Editar lista de Plays curtidas" + "read:admin:abuse-user-reports": "Ver relatórios de usuário" + "write:admin:delete-account": "Excluir conta de usuário" + "write:admin:delete-all-files-of-a-user": "Excluir todos os arquivos de um usuário" + "read:admin:index-stats": "Ver estatísticas do índice do banco de dados" + "read:admin:table-stats": "Ver estatísticas da tabela do banco de dados" + "read:admin:user-ips": "Ver endereços IP do usuário" + "read:admin:meta": "Ver metadados da instância" + "write:admin:reset-password": "Mudar a senha do usuário" + "write:admin:resolve-abuse-user-report": "Resolver relatório de usuário" + "write:admin:send-email": "Enviar email" + "read:admin:server-info": "Ver informações do servidor" + "read:admin:show-moderation-log": "Ver log de moderação" + "read:admin:show-user": "Ver informações privadas do usuário" + "write:admin:suspend-user": "Suspender usuário" + "write:admin:unset-user-avatar": "Remover avatar do usuário" + "write:admin:unset-user-banner": "Remover banner do usuário" + "write:admin:unsuspend-user": "Cancelar a suspensão do usuário" + "write:admin:meta": "Gerenciar os metadados da instância" + "write:admin:user-note": "Gerenciar a nota de moderação" + "write:admin:roles": "Gerenciar cargos" + "read:admin:roles": "Ver cargos" + "write:admin:relays": "Gerenciar relays" + "read:admin:relays": "Ver relays" + "write:admin:invite-codes": "Gerenciar códigos de convite" + "read:admin:invite-codes": "Ver códigos de convite" + "write:admin:announcements": "Gerenciar anúncios" + "read:admin:announcements": "Ver anúncios" + "write:admin:avatar-decorations": "Gerenciar decorações de avatar" + "read:admin:avatar-decorations": "Ver decorações de avatar" + "write:admin:federation": "Gerenciar dados de federação" + "write:admin:account": "Gerenciar conta de usuário" + "read:admin:account": "Ver conta de usuário" + "write:admin:emoji": "Gerenciar emoji" + "read:admin:emoji": "Ver emoji" + "write:admin:queue": "Gerenciar trabalhos pendentes" + "read:admin:queue": "Ver informações de trabalhos pendentes" + "write:admin:promo": "Gerenciar notas de promoção" + "write:admin:drive": "Gerenciar Drive de usuário" + "read:admin:drive": "Ver informações de Drive de usuário" + "read:admin:stream": "Utilizar WebSocket API para Admin" + "write:admin:ad": "Gerenciar propagandas" + "read:admin:ad": "Ver propagandas" + "write:invite-codes": "Criar códigos de convite" + "read:invite-codes": "Obter códigos de convite" + "write:clip-favorite": "Gerenciar clipes favoritados" + "read:clip-favorite": "Ver Clipes favoritados" + "read:federation": "Ver dados de federação" + "write:report-abuse": "Reportar violação" +_auth: + shareAccessTitle: "Conceder permissões do aplicativo" + shareAccess: "Você gostaria de autorizar \"{name}\" para acessar essa conta?" + shareAccessAsk: "Você tem certeza de que gostaria de conceder ao aplicativo o acesso à conta?" + permission: "{name} solicita as seguintes permissões" + permissionAsk: "O aplicativo solicita as seguintes permissões" + pleaseGoBack: "Por favor, volte ao aplicativo" + callback: "Retornando ao aplicativo" + denied: "Acesso negado" + pleaseLogin: "Por favor, entre para autorizar aplicativos." +_antennaSources: + all: "Todas as notas" + homeTimeline: "Notas de usuários seguidos" + users: "Notas de usuários específicos" + userList: "Notas de uma lista específica de usuários" + userBlacklist: "Todas as notas, exceto as de um ou mais usuários específicos" +_weekday: + sunday: "Domingo" + monday: "Segunda-feira" + tuesday: "Terça-feira" + wednesday: "Quarta-feira" + thursday: "Quinta-feira" + friday: "Sexta-feira" + saturday: "Sábado" _widgets: profile: "Perfil" instanceInfo: "Informações da instância" @@ -1394,29 +2160,112 @@ _widgets: _userList: chooseList: "Selecione uma lista" clicker: "Clicker" + birthdayFollowings: "Usuários de aniversário hoje" _cw: + hide: "Esconder" show: "Carregar mais" + chars: "{count} caracteres" + files: "{count} arquivo(s)" _poll: + noOnlyOneChoice: "São necessárias, no mínimo, duas escolhas" + choiceN: "Escolha {n}" + noMore: "Você não pode adicionar mais escolhas" canMultipleVote: "Permitir múltipla seleção" + expiration: "Encerrar enquete" + infinite: "Nunca" + at: "Terminar em..." + after: "Terminar após..." + deadlineDate: "Data de término" + deadlineTime: "Tempo" + duration: "Duração" + votesCount: "{n} votos" + totalVotes: "{n} votos totais" vote: "Votar em enquetes" + showResult: "Ver resultados" + voted: "Votada" + closed: "Encerrada" + remainingDays: "{d} dia(s) {h} hora(s) restantes" + remainingHours: "{h} hora(s) {m} minuto(s) restantes" + remainingMinutes: "{m} minuto(s) {s} segundo(s) restantes" + remainingSeconds: "{s} segundo(s) restantes" _visibility: + public: "Público" + publicDescription: "Sua nota será visível para todos os usuários" home: "Início" + homeDescription: "Publicar apenas na linha do tempo Início" followers: "Seguidores" followersDescription: "Tornar visível apenas para os meus seguidores" + specified: "Mensagem Direta" + specifiedDescription: "Tornar visível apenas para usuários específicos" + disableFederation: "Defederar" + disableFederationDescription: "Não transmitir às outras instâncias" +_postForm: + replyPlaceholder: "Responder a essa nota..." + quotePlaceholder: "Citar essa nota..." + channelPlaceholder: "Postar em canal..." + _placeholders: + a: "Como vão as coisas?" + b: "O que está rolando por aí?" + c: "No que está pensando?" + d: "Do que você quer falar?" + e: "Comece a digitar..." + f: "Esperando você digitar..." _profile: name: "Nome" username: "Nome de usuário" + description: "Bio" + youCanIncludeHashtags: "Você pode incluir hashtags em sua bio." + metadata: "Informações Adicionais" + metadataEdit: "Editar informações adicionais" + metadataDescription: "Aqui, você pode exibir campos adicionais de informação no seu perfil." + metadataLabel: "Rótulo" + metadataContent: "Conteúdo" + changeAvatar: "Mudar avatar" + changeBanner: "Mudar banner" + verifiedLinkDescription: "Ao inserir um URL que contém um link para essa conta, um ícone de verificação será exibido ao lado do campo" + avatarDecorationMax: "Você pode adicionar até {max} decorações." _exportOrImport: + allNotes: "Todas as notas" favoritedNotes: "Notas nos favoritos" clips: "Clipe" followingList: "Seguindo" muteList: "Silenciar" blockingList: "Bloquear" userLists: "Listas" + excludeMutingUsers: "Excluir usuários silenciados" + excludeInactiveUsers: "Excluir usuários inativos" + withReplies: "Incluir respostas de usuários importados na linha do tempo" _charts: federation: "União" + apRequest: "Solicitações" + usersIncDec: "Diferença no número de usuários" + usersTotal: "Número total de usuários" + activeUsers: "Usuários ativos" + notesIncDec: "Diferença no número de notas" + localNotesIncDec: "Diferença no número de notas locais" + remoteNotesIncDec: "Diferença no número de notas remotas" + notesTotal: "Número total de notas" + filesIncDec: "Diferença no número de arquivos" + filesTotal: "Número total de arquivos" + storageUsageIncDec: "Diferença no uso de armazenamento" + storageUsageTotal: "Uso total de armazenamento" +_instanceCharts: + requests: "Solicitações" + users: "Diferença no número de usuários" + usersTotal: "Número cumulativo de usuários" + notes: "Diferença no número de notas" + notesTotal: "Número cumulativo de notas" + ff: "Diferença entre número de usuários seguidos/seguidores" + ffTotal: "Número cumulativo de usuários seguidos/seguidores" + cacheSize: "Diferença do tamanho do cache" + cacheSizeTotal: "Tamanho cumulativo do cache" + files: "Diferença no número de arquivos" + filesTotal: "Número cumulativo de arquivos" _timelines: home: "Início" + local: "Local" + social: "Social" + global: "Global" _play: new: "Criar Play" edit: "Editar Play" @@ -1425,18 +2274,66 @@ _play: deleted: "Play foi excluído" pageSetting: "Configurações do Play" editThisPage: "Editar este Play" + viewSource: "Ver fonte" my: "Meus Plays" liked: "Plays curtidos" + featured: "Popular" + title: "Título" script: "Script" summary: "Descrição" + visibilityDescription: "Pôr em privado significa que ele não será visível no perfil, mas qualquer um com o URL poderá acessar" _pages: + newPage: "Criar uma Página" + editPage: "Editar essa Página" + readPage: "Ver a fonte dessa Página" + created: "Página criada com sucesso" + updated: "Página atualizada com sucesso" deleted: "Página excluída com sucesso" + pageSetting: "Configurações da página" + nameAlreadyExists: "O URL de Página especificado já existe" + invalidNameTitle: "O URL de Página especificado é inválido" + invalidNameText: "Confira se o título da Página não está vazio" + editThisPage: "Editar essa Página" + viewSource: "Ver código-fonte" viewPage: "Visualizar as suas páginas" like: "Curtir" unlike: "Remover curtida" + my: "Minhas Páginas" liked: "Páginas curtidas" + featured: "Populares" + inspector: "Inspetor" + contents: "Conteúdo" + content: "Bloco da Página" + variables: "Variáveis" + title: "Título" + url: "URL da Página" + summary: "Resumo da página" + alignCenter: "Centralizar elementos" + hideTitleWhenPinned: "Esconder título da Página quando fixado em perfil" + font: "Fonte" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageSet: "Escolher miniatura" + eyeCatchingImageRemove: "Excluir miniatura" + chooseBlock: "Adicionar bloco" + enterSectionTitle: "Insira um título à seção" + selectType: "Selecionar um tipo" + contentBlocks: "Conteúdo" + inputBlocks: "Inserir" + specialBlocks: "Especial" blocks: + text: "Texto" + textarea: "Área do texto" + section: "Seção" image: "imagem" + button: "Botão" + dynamic: "Blocos Dinâmicos" + dynamicDescription: "Esse bloco foi abolido. Por favor, use {play} de agora em diante." + note: "Nota embutida" + _note: + id: "ID da nota" + idDescription: "Você também pode colar o URL da nota aqui." + detailed: "Visão detalhada" _relayStatus: requesting: "Pendente" accepted: "Aprovado" @@ -1451,18 +2348,34 @@ _notification: youReceivedFollowRequest: "Você recebeu um pedido de seguidor" yourFollowRequestAccepted: "Seu pedido de seguidor foi aceito" pollEnded: "Os resultados da enquete agora estão disponíveis" + newNote: "Nova nota" + unreadAntennaNote: "Antena {name}" + roleAssigned: "Cargo dado" emptyPushNotificationMessage: "As notificações de alerta foram atualizadas" + achievementEarned: "Conquista desbloqueada" + testNotification: "Notificação teste" + checkNotificationBehavior: "Verificar aparência da notificação" + sendTestNotification: "Enviar notificação de teste" + notificationWillBeDisplayedLikeThis: "Notificações se parecem com isso" + reactedBySomeUsers: "{n} usuários reagiram" + likedBySomeUsers: "{n} usuários gostaram da nota" + renotedBySomeUsers: "{n} usuários repostaram a nota" + followedBySomeUsers: "{n} usuários te seguiram" + flushNotification: "Limpar notificações" _types: all: "Todas" + note: "Novas notas" follow: "Seguindo" mention: "Menção" reply: "Respostas" renote: "Repostar" - quote: "Citar" + quote: "Citações" reaction: "Reações" pollEnded: "Enquetes terminando" receiveFollowRequest: "Recebeu pedidos de seguidor" followRequestAccepted: "Aceitou pedidos de seguidor" + roleAssigned: "Cargo dado" + achievementEarned: "Conquista desbloqueada" app: "Notificações de aplicativos conectados" _actions: followBack: "te seguiu de volta" @@ -1472,13 +2385,23 @@ _deck: alwaysShowMainColumn: "Sempre mostrar a coluna principal" columnAlign: "Alinhar colunas" addColumn: "Adicionar coluna" + newNoteNotificationSettings: "Opções de notificação para novas notas" + configureColumn: "Configurar coluna" swapLeft: "Trocar de posição com a coluna à esquerda" swapRight: "Trocar de posição com a coluna à direita" swapUp: "Trocar de posição com a coluna acima" swapDown: "Trocar de posição com a coluna abaixo" + stackLeft: "Empilhar na coluna à esquerda" popRight: "Acoplar coluna à direita" profile: "Perfil" + newProfile: "Novo perfil" deleteProfile: "Remover perfil" + introduction: "Crie a interface perfeita para você arranjando as colunas livremente!" + introduction2: "Clique no + à direita da tela para adicionar novas colunas quando quiser." + widgetsIntroduction: "Por favor, selecione \"Editar widgets\" no menu em coluna e adicione um widget." + useSimpleUiForNonRootPages: "Usar UI simples para páginas navegadas" + usedAsMinWidthWhenFlexible: "A largura mínima será usada para isso quando o \"Ajuste automático da largura\" estiver ativado" + flexible: "Ajuste automático da largura" _columns: main: "Principal" widgets: "Widgets" @@ -1490,22 +2413,230 @@ _deck: mentions: "Menções" direct: "Notas diretas" roleTimeline: "Linha do tempo do cargo" +_dialog: + charactersExceeded: "Você excedeu o limite de caracteres! Atualmente em {current} de {max}." + charactersBelow: "Você está abaixo do limite mínimo de caracteres! Atualmente em {current} of {min}." +_disabledTimeline: + title: "Linha do tempo desabilitada" + description: "Você não pode acessar essa linha do tempo sob o seu cargo atual." _drivecleaner: orderBySizeDesc: "Tamanho descendente" orderByCreatedAtAsc: "Data ascendente" _webhookSettings: + createWebhook: "Criar Webhook" + modifyWebhook: "Modificar Webhook" name: "Nome" + secret: "Segredo" + trigger: "Gatilho" active: "Ativado" _events: follow: "Quando seguindo um usuário" followed: "Quando sendo seguido" + note: "Ao postar uma nota" + reply: "Quando receber uma resposta" renote: "Quando repostado" + reaction: "Quando receber uma reação" + mention: "Quando for mencionado" + _systemEvents: + abuseReport: "Quando receber um relatório de abuso" + abuseReportResolved: "Quando relatórios de abuso forem resolvidos " + userCreated: "Quando um usuário é criado" + deleteConfirm: "Você tem certeza de que deseja excluir o Webhook?" _abuseReport: _notificationRecipient: + createRecipient: "Adicionar destinatário para relatórios de abuso" + modifyRecipient: "Editar destinatários para relatórios de abuso" + recipientType: "TIpo de notificação" _recipientType: mail: "E-mail" + webhook: "Webhook" + _captions: + mail: "Enviar o email aos endereços dos moderadores ao receber relatório de abuso." + webhook: "Enviar uma notificação ao SystemWebhook quando você receber um resolver um relatório de abuso." + keywords: "Palavras-chave" + notifiedUser: "Usuários para notificar" + notifiedWebhook: "Webhook usado" + deleteConfirm: "Você tem certeza de que quer excluir o destinatário da notificação?" _moderationLogTypes: + createRole: "Cargo criado" + deleteRole: "Cargo excluído" + updateRole: "Cargo atualizado" + assignRole: "Cargo atribuído" + unassignRole: "Cargo removido" suspend: "Suspender" + unsuspend: "Suspensão cancelada" + addCustomEmoji: "Emoji personalizado adicionado" + updateCustomEmoji: "Emoji personalizado atualizado" + deleteCustomEmoji: "Emoji personalizado removido" + updateServerSettings: "Configurações de servidor atualizadas" + updateUserNote: "Nota de moderação atualizada" + deleteDriveFile: "Arquivo excluído" + deleteNote: "Nota excluída" + createGlobalAnnouncement: "Anúncio global criado" + createUserAnnouncement: "Anúncio de usuário criado" + updateGlobalAnnouncement: "Anúncio global atualizado" + updateUserAnnouncement: "Anúncio de usuário atualizado" + deleteGlobalAnnouncement: "Anúncio global excluído" + deleteUserAnnouncement: "Anúncio de usuário excluído" resetPassword: "Redefinir senha" + suspendRemoteInstance: "Instância remota suspensa" + unsuspendRemoteInstance: "Suspensão de instância remota removida" + updateRemoteInstanceNote: "Nota de moderação atualizada para instância remota." + markSensitiveDriveFile: "Arquivo marcado como sensível" + unmarkSensitiveDriveFile: "Arquivo desmarcado como sensível" + resolveAbuseReport: "Relatório resolvido" + createInvitation: "Convite gerado" + createAd: "Propaganda criada" + deleteAd: "Propaganda excluída" + updateAd: "Propaganda atualizada" + createAvatarDecoration: "Decoração de avatar criada" + updateAvatarDecoration: "Decoração de avatar atualizada" + deleteAvatarDecoration: "Decoração de avatar removida" + unsetUserAvatar: "Remover avatar de usuário" + unsetUserBanner: "Remover banner de usuário" + createSystemWebhook: "Criar SystemWebhook" + updateSystemWebhook: "Atualizar SystemWebhook" + deleteSystemWebhook: "Remover SystemWebhook" + createAbuseReportNotificationRecipient: "Criar um destinatário para relatórios de abuso" + updateAbuseReportNotificationRecipient: "Atualizar destinatários para relatórios de abuso" + deleteAbuseReportNotificationRecipient: "Remover um destinatário para relatórios de abuso" + deleteAccount: "Remover conta" + deletePage: "Remover página" + deleteFlash: "Remover Play" + deleteGalleryPost: "Remover a publicação da galeria" +_fileViewer: + title: "Detalhes do arquivo" + type: "Tipo de arquivo" + size: "Tamanho do arquivo" + url: "URL" + uploadedAt: "Adicionado em" + attachedNotes: "Notas anexadas" + thisPageCanBeSeenFromTheAuthor: "Essa página só pode ser vista pelo usuário que enviou esse arquivo." +_externalResourceInstaller: + title: "Instalar de site externo" + checkVendorBeforeInstall: "Tenha certeza de que o distribuidor desse recurso é confiável antes da instalação." + _plugin: + title: "Deseja instalar esse plugin?" + metaTitle: "Informações do plugin" + _theme: + title: "Deseja instalar esse tema?" + metaTitle: "Informações do tema" + _meta: + base: "Paleta de cores base" + _vendorInfo: + title: "Informações do distribuidor" + endpoint: "Endpoint referenciado" + hashVerify: "Verificação de hashes" + _errors: + _invalidParams: + title: "Parâmetros inválidos" + description: "Não há informações suficientes para carregar dados do site externo. Por favor, confirme o URL inserido." + _resourceTypeNotSupported: + title: "Esse recurso externo é incompatível" + description: "Esse tipo de recuso externo é incompatível. Por favor, comunique o administrador do site." + _failedToFetch: + title: "Não foi possível obter dados" + fetchErrorDescription: "Houve um erro ao comunicar com o site externo. Se tentar novamente não resolver o problema, contate o administrador do site." + parseErrorDescription: "Houve um erro processando os dados do site externo. Por favor, contate o administrador do site." + _hashUnmatched: + title: "Verificação de dados falhou" + description: "Houve um erro verificando a integridade do conteúdo obtido. Como medida de segurança, a instalação foi interrompida. Por favor, contate o administrador do site." + _pluginParseFailed: + title: "Erro AiScript" + description: "Os dados solicitados foram obtidos com sucesso, mas houve um erro na leitura do AiScript. Por favor, contate o autor do plugin. Detalhes de erro podem ser vistos no console Javascript." + _pluginInstallFailed: + title: "A instalação do plugin falhou." + description: "Houve um problema na instalação do plugin. Por favor, tente novamente. Detalhes de erro podem ser vistos no console Javascript." + _themeParseFailed: + title: "Erro na leitura do tema" + description: "Os dados solicitados foram obtidos com sucesso, mas houve um erro na leitura do tema. Por favor, contate o autor do tema. Detalhes de erro podem ser vistos no console Javascript." + _themeInstallFailed: + title: "Falha ao instalar tema" + description: "Houve um problema na instalação do tema. Por favor, tente novamente. Detalhes do erro podem ser vistos no console Javascript." +_dataSaver: + _media: + title: "Carregando mídia" + description: "Previne que mídia seja carregada automaticamente. Mídias escondidas serão carregadas quando selecionadas." + _avatar: + title: "Imagem do avatar" + description: "Parar animação de avatares. Imagens animadas podem ter um arquivo mais pesado do que imagens normais, potencialmente levando a reduções no tráfego de dados." + _urlPreview: + title: "Miniaturas na prévia de URLs" + description: "Miniaturas na prévia de URLs não serão mais carregadas." + _code: + title: "Destaque de código" + description: "Se as notações de formatação de código forem utilizadas em MFM, elas não irão carregar até serem selecionadas. Destaque de código exige baixar arquivos de alta definição para cada linguagem de programação. Logo, desabilitar o carregamento automático desses arquivos diminui a quantidade de informação comunicada." +_hemisphere: + N: "Hemisfério Norte" + S: "Hemisfério Sul" + caption: "Utilizado em algumas configurações de aplicativo para determinar a estação do ano." _reversi: + reversi: "Reversi" + gameSettings: "Configurações de jogo" + chooseBoard: "Escolha um tabuleiro" + blackOrWhite: "Preto/Branco" + blackIs: "{name} é as peças Pretas" + rules: "Regras" + thisGameIsStartedSoon: "O jogo começará em breve" + waitingForOther: "Esperando o turno do oponente" + waitingForMe: "Esperando o seu turno" + waitingBoth: "Prepare-se" + ready: "Pronto" + cancelReady: "Não pronto" + opponentTurn: "Turno do oponente" + myTurn: "Seu turno" + turnOf: "É o turno de {name}" + pastTurnOf: "Turno de {name}" + surrender: "Desistir" + surrendered: "Desistiu" + timeout: "Fim do tempo" + drawn: "Empate" + won: "{name} venceu" + black: "Preto" + white: "Branco" total: "Total" + turnCount: "Turno {count}" + myGames: "Meus jogos" + allGames: "Todos os jogos" + ended: "Terminado" + playing: "Atualmente jogando" + isLlotheo: "Aquele com menos pedras vence (Llotheo)" + loopedMap: "Mapa em ‘loop’" + canPutEverywhere: "É possível pôr em qualquer lugar" + timeLimitForEachTurn: "Tempo limite por turno" + freeMatch: "Partida Livre" + lookingForPlayer: "À procura de adversários..." + gameCanceled: "A partida foi cancelada." + shareToTlTheGameWhenStart: "Compartilhar jogo na linha do tempo ao iniciar" + iStartedAGame: "O jogo começou! #MisskeyReversi" + opponentHasSettingsChanged: "O oponente alterou as configurações dele" + allowIrregularRules: "Regras irregulares (completamente livre)" + disallowIrregularRules: "Sem regras irregulares" + showBoardLabels: "Exibir numeração de linha e coluna no tabuleiro" + useAvatarAsStone: "Utilizar avatares de usuário como as pedras" +_offlineScreen: + title: "Offline - não foi possível conectar ao servidor" + header: "Não foi possível conectar ao servidor" +_urlPreviewSetting: + title: "Configurações da prévia de URL" + enable: "Habilitar prévia de URL" + timeout: "Tempo máximo para obter a prévia (ms)" + timeoutDescription: "Se demorar mais que esse valor para obter uma prévia, ela não será gerada." + maximumContentLength: "Content-Length máximo (em bytes)" + maximumContentLengthDescription: "Se o Content-Length for maior que esse valor, a prévia não será gerada." + requireContentLength: "Gerar previu apenas se houver cabeçalho Content-Length disponível na solicitação" + requireContentLengthDescription: "Se o outro servidor não retornar um cabeçalho Content-Length, a prévia não será gerada." + userAgent: "User-Agent" + userAgentDescription: "Define o User-Agent a ser usado ao gerar prévias. Se for deixado em branco, será usado o User-Agent padrão." + summaryProxy: "Endpoints do Proxy que geram prévias" + summaryProxyDescription: "Fora do Misskey, gerar prévias usando o Sumally Proxy." + summaryProxyDescription2: "Os parâmetros a seguir são vinculados ao proxy como um 'query string'. Se o proxy não os suportar, os valores serão ignorados." +_mediaControls: + pip: "Picture-in-Picture" + playbackRate: "Velocidade de Reprodução" + loop: "Reprodução em Loop" +_contextMenu: + title: "Menu de contexto" + app: "Aplicativo" + appWithShift: "Aplicativo com a tecla shift" + native: "Nativo" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index b4c9b90de9c8..a5f805786080 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -453,7 +453,6 @@ or: "Sau" language: "Limbă" uiLanguage: "Limba interfeței" aboutX: "Despre {x}" -disableDrawer: "Nu folosi meniuri în stil sertar" noHistory: "Nu există istoric" signinHistory: "Istoric autentificări" doing: "Se procesează..." diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 88f59155d656..cdc4898a3b10 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -2,7 +2,7 @@ _lang_: "Русский" headlineMisskey: "Сеть, сплетённая из заметок" introMisskey: "Добро пожаловать! Misskey — это децентрализованный сервис микроблогов с открытым исходным кодом.\nПишите «заметки» — делитесь со всеми происходящим вокруг или рассказывайте о себе 📡\nСтавьте «реакции» — выражайте свои чувства и эмоции от заметок других 👍\nОткройте для себя новый мир 🚀" -poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом Misskey, называемый инстансом Misskey." +poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом Misskey, называемый экземпляром Misskey." monthAndDay: "{day}.{month}" search: "Поиск" notifications: "Уведомления" @@ -10,15 +10,15 @@ username: "Имя пользователя" password: "Пароль" forgotPassword: "Забыли пароль?" fetchingAsApObject: "Приём с других сайтов" -ok: "Окей" +ok: "Подтвердить" gotIt: "Ясно!" cancel: "Отмена" noThankYou: "Нет, спасибо" enterUsername: "Введите имя пользователя" -renotedBy: "{user} делится" +renotedBy: "{user} репостнул(а)" noNotes: "Нет ни одной заметки" noNotifications: "Нет уведомлений" -instance: "Инстанс" +instance: "Экземпляр" settings: "Настройки" notificationSettings: "Настройки уведомлений" basicSettings: "Основные настройки" @@ -45,22 +45,24 @@ pin: "Закрепить в профиле" unpin: "Открепить от профиля" copyContent: "Скопировать содержимое" copyLink: "Скопировать ссылку" +copyLinkRenote: "Скопировать ссылку на репост" delete: "Удалить" deleteAndEdit: "Удалить и отредактировать" -deleteAndEditConfirm: "Удалить эту заметку и создать отредактированную? Все реакции, ссылки и ответы на существующую будут будут потеряны." +deleteAndEditConfirm: "Удалить этот пост и отредактировать заново? Все реакции, репосты и ответы на него также будут удалены." addToList: "Добавить в список" addToAntenna: "Добавить к антенне" sendMessage: "Отправить сообщение" copyRSS: "Скопировать RSS" copyUsername: "Скопировать имя пользователя" -copyUserId: "Скопировать идентификатор пользователя" -copyNoteId: "Скопировать идентификатор заметки" +copyUserId: "Скопировать ID пользователя" +copyNoteId: "Скопировать ID поста" copyFileId: "Скопировать ID файла" copyFolderId: "Скопировать ID папки" -copyProfileUrl: "Скопировать URL профиля " +copyProfileUrl: "Скопировать ссылку на профиль" searchUser: "Поиск людей" +searchThisUsersNotes: "Искать по заметкам пользователя" reply: "Ответ" -loadMore: "Показать еще" +loadMore: "Загрузить ещё" showMore: "Показать ещё" showLess: "Закрыть" youGotNewFollower: "Новый подписчик" @@ -107,11 +109,14 @@ enterEmoji: "Введите эмодзи" renote: "Репост" unrenote: "Отмена репоста" renoted: "Репост совершён." +renotedToX: "Репостнуть в {name}." cantRenote: "Это нельзя репостить." cantReRenote: "Невозможно репостить репост." quote: "Цитата" inChannelRenote: "В канале" inChannelQuote: "Заметки в канале" +renoteToChannel: "Репостнуть в канал" +renoteToOtherChannel: "Репостнуть в другой канал" pinnedNote: "Закреплённая заметка" pinned: "Закрепить в профиле" you: "Вы" @@ -150,6 +155,7 @@ editList: "Редактировать список" selectChannel: "Выберите канал" selectAntenna: "Выберите антенну" editAntenna: "Редактировать антенну" +createAntenna: "Создать антенну" selectWidget: "Выберите виджет" editWidgets: "Редактировать виджеты" editWidgetsExit: "Готово" @@ -157,11 +163,12 @@ customEmojis: "Собственные эмодзи" emoji: "Эмодзи" emojis: "Эмодзи" emojiName: "Название эмодзи" -emojiUrl: "URL эмодзи" +emojiUrl: "Ссылка на эмодзи" addEmoji: "Добавить эмодзи" settingGuide: "Рекомендуемые настройки" cacheRemoteFiles: "Кешировать внешние файлы" cacheRemoteFilesDescription: "Когда эта настройка отключена, файлы с других сайтов будут загружаться прямо оттуда. Это сэкономит место на сервере, но увеличит трафик, так как не будут создаваться эскизы." +youCanCleanRemoteFilesCache: "Вы можете очистить кэш, нажав на кнопку 🗑️ в меню управления файлами." cacheRemoteSensitiveFiles: "Кэшировать внешние файлы «не для всех»" cacheRemoteSensitiveFilesDescription: "Если отключено, файлы «не для всех» загружаются непосредственно с удалённых серверов, не кэшируясь." flagAsBot: "Аккаунт бота" @@ -175,6 +182,10 @@ addAccount: "Добавить учётную запись" reloadAccountsList: "Обновить список учётных записей" loginFailed: "Неудачная попытка входа" showOnRemote: "Перейти к оригиналу на сайт" +continueOnRemote: "Продолжить на удалённом сервере" +chooseServerOnMisskeyHub: "Выбрать сервер с Misskey Hub" +specifyServerHost: "Укажите сервер напрямую" +inputHostName: "Введите домен" general: "Общее" wallpaper: "Обои" setWallpaper: "Установить обои" @@ -185,6 +196,7 @@ followConfirm: "Подписаться на {name}?" proxyAccount: "Учётная запись прокси" proxyAccountDescription: "Учетная запись прокси предназначена служить подписчиком на пользователей с других сайтов. Например, если пользователь добавит кого-то с другого сайта а список, деятельность того не отобразится, пока никто с этого же сайта не подписан на него. Чтобы это стало возможным, на него подписывается прокси." host: "Хост" +selectSelf: "Выбрать себя" selectUser: "Выберите пользователя" recipient: "Кому" annotation: "Описание" @@ -199,6 +211,7 @@ perHour: "По часам" perDay: "По дням" stopActivityDelivery: "Остановить отправку обновлений активности" blockThisInstance: "Блокировать этот инстанс" +silenceThisInstance: "Заглушить этот инстанс" operations: "Операции" software: "Программы" version: "Версия" @@ -218,6 +231,7 @@ clearCachedFiles: "Очистить кэш" clearCachedFilesConfirm: "Удалить все закэшированные файлы с других сайтов?" blockedInstances: "Заблокированные инстансы" blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом." +silencedInstances: "Заглушённые инстансы" muteAndBlock: "Скрытие и блокировка" mutedUsers: "Скрытые пользователи" blockedUsers: "Заблокированные пользователи" @@ -236,7 +250,7 @@ noJobs: "Нет заданий" federating: "Федерируется" blocked: "Заблокировано" suspended: "Заморожено" -all: "Всё" +all: "Все" subscribing: "Подписка" publishing: "Публикация" notResponding: "Нет ответа" @@ -268,7 +282,7 @@ messaging: "Сообщения" upload: "Загрузить" keepOriginalUploading: "Сохранить исходное изображение" keepOriginalUploadingDescription: "Сохраняет исходную версию при загрузке изображений. Если выключить, то при загрузке браузер генерирует изображение для публикации." -fromDrive: "С «диска»" +fromDrive: "С Диска" fromUrl: "По ссылке" uploadFromUrl: "Загрузить по ссылке" uploadFromUrlDescription: "Ссылка на файл, который хотите загрузить" @@ -308,6 +322,7 @@ selectFile: "Выберите файл" selectFiles: "Выберите файлы" selectFolder: "Выберите папку" selectFolders: "Выберите папки" +fileNotSelected: "Файл не выбран" renameFile: "Переименовать файл" folderName: "Имя папки" createFolder: "Создать папку" @@ -359,8 +374,8 @@ disablingTimelinesInfo: "У администраторов и модератор registration: "Регистрация" enableRegistration: "Разрешить регистрацию" invite: "Пригласить" -driveCapacityPerLocalAccount: "Объём диска на одного локального пользователя" -driveCapacityPerRemoteAccount: "Объём диска на одного пользователя с другого сайта" +driveCapacityPerLocalAccount: "Объём Диска на одного локального пользователя" +driveCapacityPerRemoteAccount: "Объём Диска на одного пользователя с другого экземпляра" inMb: "В мегабайтах" bannerUrl: "Ссылка на изображение в шапке" backgroundImageUrl: "Ссылка на фоновое изображение" @@ -379,6 +394,7 @@ mcaptcha: "mCaptcha" enableMcaptcha: "Включить mCaptcha" mcaptchaSiteKey: "Ключ сайта" mcaptchaSecretKey: "Секретный ключ" +mcaptchaInstanceUrl: "Ссылка на сервер mCaptcha" recaptcha: "reCAPTCHA" enableRecaptcha: "Включить reCAPTCHA" recaptchaSiteKey: "Ключ сайта" @@ -393,7 +409,8 @@ manageAntennas: "Настройки антенн" name: "Название" antennaSource: "Источник антенны" antennaKeywords: "Ключевые слова" -antennaExcludeKeywords: "Исключения" +antennaExcludeKeywords: "Чёрный список слов" +antennaExcludeBots: "Исключать ботов" antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы ловить любые из них." notifyAntenna: "Уведомлять о новых заметках" withFileAntenna: "Только заметки с вложениями" @@ -426,6 +443,7 @@ totp: "Приложение-аутентификатор" totpDescription: "Описание приложения-аутентификатора" moderator: "Модератор" moderation: "Модерация" +moderationLogs: "Журнал модерации" nUsersMentioned: "Упомянуло пользователей: {n}" securityKeyAndPasskey: "Ключ безопасности и парольная фраза" securityKey: "Ключ безопасности" @@ -458,10 +476,12 @@ retype: "Введите ещё раз" noteOf: "Что пишет {user}" quoteAttached: "Цитата" quoteQuestion: "Хотите добавить цитату?" +attachAsFileQuestion: "Текста в буфере обмена слишком много. Прикрепить как текстовый файл?" noMessagesYet: "Пока ни одного сообщения" newMessageExists: "Новое сообщение" onlyOneFileCanBeAttached: "К сообщению можно прикрепить только один файл" signinRequired: "Пожалуйста, войдите" +signinOrContinueOnRemote: "Чтобы продолжить, вам необходимо войти в аккаунт на своём сервере или зарегистрироваться / войти в аккаунт на этом." invitations: "Приглашения" invitationCode: "Код приглашения" checking: "Проверка" @@ -471,7 +491,7 @@ usernameInvalidFormat: "Можно использовать только лат tooShort: "Слишком короткий" tooLong: "Слишком длинный" weakPassword: "Слабый пароль" -normalPassword: "Годный пароль" +normalPassword: "Хороший пароль" strongPassword: "Надёжный пароль" passwordMatched: "Совпали" passwordNotMatched: "Не совпадают" @@ -483,8 +503,8 @@ uiLanguage: "Язык интерфейса" aboutX: "Описание {x}" emojiStyle: "Стиль эмодзи" native: "Системные" -disableDrawer: "Не использовать выдвижные меню" showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении" +showReactionsCount: "Видеть количество реакций на заметках" noHistory: "История пока пуста" signinHistory: "Журнал посещений" enableAdvancedMfm: "Включить расширенный MFM" @@ -547,7 +567,7 @@ popout: "Развернуть" volume: "Громкость" masterVolume: "Основная регулировка громкости" notUseSound: "Выключить звук" -useSoundOnlyWhenActive: "Использовать звук, когда Misskey активен." +useSoundOnlyWhenActive: "Воспроизводить звук только когда Misskey активен." details: "Подробнее" chooseEmoji: "Выберите эмодзи" unableToProcess: "Не удаётся завершить операцию" @@ -601,7 +621,7 @@ poll: "Опрос" useCw: "Скрывать содержимое под предупреждением" enablePlayer: "Включить проигрыватель" disablePlayer: "Выключить проигрыватель" -expandTweet: "Развернуть твит" +expandTweet: "Развернуть заметку" themeEditor: "Редактор темы оформления" description: "Описание" describeFile: "Добавить подпись" @@ -613,7 +633,7 @@ plugins: "Расширения" preferencesBackups: "Резервная копия" deck: "Пульт" undeck: "Покинуть пульт" -useBlurEffectForModal: "Размывка под формой поверх всего" +useBlurEffectForModal: "Размытие за формой ввода заметки" useFullReactionPicker: "Полнофункциональный выбор реакций" width: "Ширина" height: "Высота" @@ -644,7 +664,7 @@ smtpSecure: "Использовать SSL/TLS для SMTP-соединений" smtpSecureInfo: "Выключите при использовании STARTTLS." testEmail: "Проверка доставки электронной почты" wordMute: "Скрытие слов" -hardWordMute: "" +hardWordMute: "Строгое скрытие слов" regexpError: "Ошибка в регулярном выражении" regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:" instanceMute: "Глушение инстансов" @@ -726,6 +746,7 @@ lockedAccountInfo: "Даже если вы вручную подтверждае alwaysMarkSensitive: "Отмечать файлы как «содержимое не для всех» по умолчанию" loadRawImages: "Сразу показывать изображения в полном размере" disableShowingAnimatedImages: "Не проигрывать анимацию" +highlightSensitiveMedia: "Выделять содержимое не для всех" verificationEmailSent: "Вам отправлено письмо для подтверждения. Пройдите, пожалуйста, по ссылке из письма, чтобы завершить проверку." notSet: "Не настроено" emailVerified: "Адрес электронной почты подтверждён." @@ -743,7 +764,7 @@ makeExplorable: "Опубликовать профиль в «Обзоре»." makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»." showGapBetweenNotesInTimeline: "Показывать разделитель между заметками в ленте" duplicate: "Дубликат" -left: "Влево" +left: "Слева" center: "По центру" wide: "Толстый" narrow: "Тонкий" @@ -822,7 +843,7 @@ noMaintainerInformationWarning: "Не заполнены сведения об noBotProtectionWarning: "Ботозащита не настроена" configure: "Настроить" postToGallery: "Опубликовать в галерею" -postToHashtag: "Написать заметку с этим хэштегом" +postToHashtag: "Написать заметку с этим хештегом" gallery: "Галерея" recentPosts: "Недавние публикации" popularPosts: "Популярные публикации" @@ -839,13 +860,13 @@ emailNotConfiguredWarning: "Не указан адрес электронной ratio: "Соотношение" previewNoteText: "Предварительный просмотр" customCss: "Индивидуальный CSS" -customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки здесь чреваты тем, что сайт перестанет нормально работать у вас." +customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки здесь чреваты тем, что у вас перестанет нормально работать сайт." global: "Всеобщая" squareAvatars: "Квадратные аватарки" sent: "Отправить" received: "Получено" searchResult: "Результаты поиска" -hashtags: "Хэштег" +hashtags: "Хештеги" troubleshooting: "Разрешение проблем" useBlurEffect: "Размытие в интерфейсе" learnMore: "Подробнее" @@ -857,7 +878,7 @@ accountDeletionInProgress: "В настоящее время выполняет usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания (_). Имена пользователей не могут быть изменены позже." aiChanMode: "Режим Ай" devMode: "Режим разработчика" -keepCw: "Сохраняйте Предупреждения о содержимом" +keepCw: "Сохраняйте предупреждения о содержимом" pubSub: "Учётные записи Pub/Sub" lastCommunication: "Последнее сообщение" resolved: "Решено" @@ -878,6 +899,8 @@ makeReactionsPublicDescription: "Список сделанных вами реа classic: "Классика" muteThread: "Скрыть цепочку" unmuteThread: "Отменить сокрытие цепочки" +followingVisibility: "Видимость подписок" +followersVisibility: "Видимость подписчиков" continueThread: "Показать следующие ответы" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" incorrectPassword: "Пароль неверен." @@ -987,6 +1010,7 @@ assign: "Назначить" unassign: "Отменить назначение" color: "Цвет" manageCustomEmojis: "Управлять пользовательскими эмодзи" +manageAvatarDecorations: "Управление украшениями аватара" youCannotCreateAnymore: "Вы достигли лимита создания." cannotPerformTemporary: "Временно недоступен" cannotPerformTemporaryDescription: "Это действие временно невозможно выполнить из-за превышения лимита выполнения." @@ -1003,7 +1027,8 @@ thisPostMayBeAnnoying: "Это сообщение может быть непри thisPostMayBeAnnoyingHome: "Этот пост может быть отправлен на главную" thisPostMayBeAnnoyingCancel: "Этот пост не может быть отменен." thisPostMayBeAnnoyingIgnore: "Этот пост может быть проигнорирован " -collapseRenotes: "Свернуть репосты" +collapseRenotes: "Сворачивать увиденные репосты" +collapseRenotesDescription: "Сворачивать посты с которыми вы взаимодействовали." internalServerError: "Внутренняя ошибка сервера" internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка." copyErrorInfo: "Скопировать код ошибки" @@ -1027,7 +1052,10 @@ resetPasswordConfirm: "Сбросить пароль?" sensitiveWords: "Чувствительные слова" sensitiveWordsDescription: "Установите общедоступный диапазон заметки, содержащей заданное слово, на домашний. Можно сделать несколько настроек, разделив их переносами строк." sensitiveWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." +prohibitedWords: "Запрещённые слова" +prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой." prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." +hiddenTags: "Скрытые хештеги" notesSearchNotAvailable: "Поиск заметок недоступен" license: "Лицензия" unfavoriteConfirm: "Удалить избранное?" @@ -1038,9 +1066,14 @@ retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?" retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" +showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой" +reactionsDisplaySize: "Размер реакций" +limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере." noteIdOrUrl: "ID или ссылка на заметку" video: "Видео" videos: "Видео" +audio: "Звук" +audioFiles: "Звуковые файлы" dataSaver: "Экономия трафика" accountMigration: "Перенос учётной записи" accountMoved: "Учётная запись перенесена" @@ -1052,12 +1085,13 @@ editMemo: "Изменить памятку" reactionsList: "Список реакций" renotesList: "Репосты" notificationDisplay: "Отображение уведомлений" -leftTop: "Влево вверх" -rightTop: "Вправо вверх" -leftBottom: "Влево вниз" -rightBottom: "Вправо вниз" -vertical: "Вертикальная" -horizontal: "Сбоку" +leftTop: "Слева вверху" +rightTop: "Справа сверху" +leftBottom: "Слева внизу" +rightBottom: "Справа внизу" +stackAxis: "Положение уведомлений" +vertical: "Вертикально" +horizontal: "Горизонтально" position: "Позиция" serverRules: "Правила сервера" pleaseConfirmBelowBeforeSignup: "Для регистрации на данном сервере, необходимо согласится с нижеследующими положениями." @@ -1069,57 +1103,114 @@ createNoteFromTheFile: "Создать заметку из этого файла archive: "Архив" channelArchiveConfirmTitle: "Переместить {name} в архив?" channelArchiveConfirmDescription: "Архивированные каналы перестанут отображаться в списке каналов или результатах поиска. В них также нельзя будет добавлять новые записи." +thisChannelArchived: "Этот канал находится в архиве." displayOfNote: "Отображение заметок" initialAccountSetting: "Настройка профиля" youFollowing: "Подписки" preventAiLearning: "Отказаться от использования в машинном обучении (Генеративный ИИ)" +preventAiLearningDescription: "Запросить краулеров не использовать опубликованный текст или изображения и т.д. для машинного обучения (Прогнозирующий / Генеративный ИИ) датасетов. Это достигается путём добавления \"noai\" HTTP-заголовка в ответ на соответствующий контент. Полного предотвращения через этот заголовок не избежать, так как он может быть просто проигнорирован." options: "Настройки ролей" specifyUser: "Указанный пользователь" +openTagPageConfirm: "Открыть страницу этого хештега?" +specifyHost: "Указать сайт" failedToPreviewUrl: "Предварительный просмотр недоступен" update: "Обновить" rolesThatCanBeUsedThisEmojiAsReaction: "Роли тех, кому можно использовать эти эмодзи как реакцию" rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Если здесь ничего не указать, в качестве реакции эту эмодзи сможет использовать каждый." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Эти роли должны быть общедоступными." +cancelReactionConfirm: "Вы действительно хотите удалить свою реакцию?" later: "Позже" goToMisskey: "К Misskey" additionalEmojiDictionary: "Дополнительные словари эмодзи" installed: "Установлено" branding: "Бренд" +enableServerMachineStats: "Опубликовать характеристики сервера" enableIdenticonGeneration: "Включить генерацию иконки пользователя" turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность." +createInviteCode: "Создать код приглашения" +createCount: "Количество приглашений" expirationDate: "Дата истечения" -unused: "Неиспользуемый" +noExpirationDate: "Бессрочно" +unused: "Неиспользованное" +used: "Использован" expired: "Срок действия приглашения истёк" doYouAgree: "Согласны?" icon: "Аватар" replies: "Ответы" renotes: "Репост" loadReplies: "Показать ответы" +pinnedList: "Закреплённый список" +keepScreenOn: "Держать экран включённым" +showRenotes: "Показывать репосты" +mutualFollow: "Взаимные подписки" +followingOrFollower: "Подписки или подписчики" +fileAttachedOnly: "Только заметки с файлами" +showRepliesToOthersInTimeline: "Показывать ответы в ленте" +showRepliesToOthersInTimelineAll: "Показывать в ленте ответы пользователей, на которых вы подписаны" +hideRepliesToOthersInTimelineAll: "Скрывать в ленте ответы пользователей, на которых вы подписаны" sourceCode: "Исходный код" +sourceCodeIsNotYetProvided: "Исходный код пока не доступен. Свяжитесь с администратором, чтобы исправить эту проблему." +repositoryUrl: "Ссылка на репозиторий" +repositoryUrlDescription: "Если вы используете Misskey как есть (без изменений в исходном коде), введите https://github.com/misskey-dev/misskey" +privacyPolicy: "Политика Конфиденциальности" +privacyPolicyUrl: "Ссылка на Политику Конфиденциальности" +attach: "Прикрепить" +angle: "Угол" flip: "Переворот" +disableStreamingTimeline: "Отключить обновление ленты в режиме реального времени" +useGroupedNotifications: "Отображать уведомления сгруппировано" +doReaction: "Добавить реакцию" code: "Код" +remainingN: "Остаётся: {n}" +seasonalScreenEffect: "Эффект времени года на экране" +decorate: "Украсить" +addMfmFunction: "Добавить MFM" lastNDays: "Последние {n} сут" +hemisphere: "Место проживания" +enableHorizontalSwipe: "Смахните в сторону, чтобы сменить вкладки" surrender: "Этот пост не может быть отменен." +useNativeUIForVideoAudioPlayer: "Использовать интерфейс браузера при проигрывании видео и звука" +keepOriginalFilename: "Сохранять исходное имя файла" +keepOriginalFilenameDescription: "Если вы выключите данную настройку, имена файлов будут автоматически заменены случайной строкой при загрузке." +alwaysConfirmFollow: "Всегда подтверждать подписку" +inquiry: "Связаться" _delivery: stop: "Заморожено" _type: none: "Публикация" +_announcement: + tooManyActiveAnnouncementDescription: "Большое количество оповещений может ухудшить пользовательский опыт. Рассмотрите архивирование неактуальных оповещений. " _initialAccountSetting: accountCreated: "Аккаунт успешно создан!" letsStartAccountSetup: "Давайте настроим вашу учётную запись." profileSetting: "Настройки профиля" privacySetting: "Настройки конфиденциальности" initialAccountSettingCompleted: "Первоначальная настройка успешно завершена!" + startTutorial: "Пройти Обучение" skipAreYouSure: "Пропустить настройку?" _initialTutorial: + launchTutorial: "Пройти обучение" _note: description: "Посты в Misskey называются 'Заметками.' Заметки отсортированы в хронологическом порядке в ленте и обновляются в режиме реального времени." + _reaction: + reactToContinue: "Добавьте реакцию, чтобы продолжить." + _postNote: + _visibility: + public: "Твоя заметка будет видна всем." + doNotSendConfidencialOnDirect2: "Администратор целевого сервера может видеть что вы отправляете. Будьте осторожны с конфиденциальной информацией, когда отправляете личные заметки пользователям с ненадёжных серверов." _timelineDescription: home: "В персональной ленте располагаются заметки тех, на которых вы подписаны." - local: "Местная лента показывает заметки всех пользователей этого сайта." + local: "Местная лента показывает заметки всех пользователей этого экземпляра." social: "В социальной ленте собирается всё, что есть в персональной и местной лентах." - global: "В глобальную ленту попадает вообще всё со связанных инстансов." + global: "В глобальную ленту попадает вообще всё со связанных экземпляров." _serverSettings: iconUrl: "Адрес на иконку роли" +_accountMigration: + moveFrom: "Перенести другую учётную запись сюда" + moveTo: "Перенести учётную запись на другой сервер" + moveAccountDescription: "Это действие перенесёт ваш аккаунт на другой сервер.\n ・Подписчики с этого аккаунта автоматически подпишутся на новый\n ・Этот аккаунт отпишется от всех пользователей, на которых подписан сейчас\n ・Вы не сможете создавать новые заметки и т.д. на этом аккаунте\n\nТогда как перенос подписчиков происходит автоматически, вы должны будете подготовиться, сделав некоторые шаги, чтобы перенести список пользователей, на которых вы подписаны. Чтобы сделать это, экспортируйте список подписчиков в файл, который затем импортируете на новом аккаунте в меню настроек. То же самое необходимо будет сделать со списками, также как и со скрытыми и заблокированными пользователями.\n\n(Это объяснение применяется к Misskey v13.12.0 и выше. Другое ActivityPub программное обеспечение, такое, как Mastodon, может работать по-другому." + startMigration: "Перенести" + movedAndCannotBeUndone: "Аккаунт был перемещён. Это действие необратимо." _achievements: earnedAt: "Разблокировано в" _types: @@ -1395,6 +1486,7 @@ _role: canPublicNote: "Может публиковать общедоступные заметки" canInvite: "Может создавать пригласительные коды" canManageCustomEmojis: "Управлять пользовательскими эмодзи" + canManageAvatarDecorations: "Управление украшениями аватара" driveCapacity: "Доступное пространство на «диске»" alwaysMarkNsfw: "Всегда отмечать файлы как «не для всех»" pinMax: "Доступное количество закреплённых заметок" @@ -1505,6 +1597,11 @@ _aboutMisskey: donate: "Пожертвование на Misskey" morePatrons: "Большое спасибо и многим другим, кто принял участие в этом проекте! 🥰" patrons: "Материальная поддержка" + projectMembers: "Участники проекта" +_displayOfSensitiveMedia: + respect: "Скрывать содержимое не для всех" + ignore: "Показывать содержимое не для всех" + force: "Скрывать всё содержимое" _instanceTicker: none: "Не показывать" remote: "Только для других сайтов" @@ -1533,7 +1630,7 @@ _wordMute: muteWordsDescription: "Пишите слова через пробел в одной строке, чтобы фильтровать их появление вместе; а если хотите фильтровать любое из них, пишите в отдельных строках." muteWordsDescription2: "Здесь можно использовать регулярные выражения — просто заключите их между двумя дробными чертами (/)." _instanceMute: - instanceMuteDescription: "Заметки и репосты с указанных здесь инстансов, а также ответы пользователям оттуда же не будут отображаться." + instanceMuteDescription: "Любые активности, затрагивающие инстансы из данного списка, будут скрыты." instanceMuteDescription2: "Пишите каждый инстанс на отдельной строке" title: "Скрывает заметки с заданных инстансов." heading: "Список скрытых инстансов" @@ -1582,7 +1679,7 @@ _theme: navActive: "Текст на боковой панели (активирован)" navIndicator: "Индикатор на боковой панели" link: "Ссылка" - hashtag: "Хэштег" + hashtag: "Хештег" mention: "Упоминание" mentionMe: "Упоминания вас" renote: "Репост" @@ -1612,6 +1709,10 @@ _sfx: note: "Заметки" noteMy: "Собственные заметки" notification: "Уведомления" + reaction: "При выборе реакции" +_soundSettings: + driveFile: "Использовать аудиофайл с Диска." + driveFileWarn: "Выбрать аудиофайл с Диска." _ago: future: "Из будущего" justNow: "Только что" @@ -1690,6 +1791,7 @@ _permissions: "write:gallery": "Редактирование галереи" "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" + "write:admin:reset-password": "Сбросить пароль пользователю" _auth: shareAccessTitle: "Разрешения для приложений" shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" @@ -1743,6 +1845,7 @@ _widgets: _userList: chooseList: "Выберите список" clicker: "Счётчик щелчков" + birthdayFollowings: "Пользователи, у которых сегодня день рождения" _cw: hide: "Спрятать" show: "Показать" @@ -1796,7 +1899,7 @@ _profile: name: "Имя" username: "Имя пользователя" description: "О себе" - youCanIncludeHashtags: "Можете использовать здесь хэштеги" + youCanIncludeHashtags: "Можете использовать здесь хештеги." metadata: "Дополнительные сведения" metadataEdit: "Редактировать дополнительные сведения" metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль." @@ -1804,6 +1907,8 @@ _profile: metadataContent: "Содержимое" changeAvatar: "Поменять аватар" changeBanner: "Поменять изображение в шапке" + verifiedLinkDescription: "Указывая здесь URL, содержащий ссылку на профиль, иконка владения ресурсом может быть отображена рядом с полем" + avatarDecorationMax: "Вы можете добавить до {max} украшений." _exportOrImport: allNotes: "Все заметки\n" favoritedNotes: "Избранное" @@ -1926,6 +2031,9 @@ _notification: unreadAntennaNote: "Антенна {name}" emptyPushNotificationMessage: "Обновлены push-уведомления" achievementEarned: "Получено достижение" + checkNotificationBehavior: "Проверить внешний вид уведомления" + sendTestNotification: "Отправить тестовое уведомление" + flushNotification: "Очистить уведомления" _types: all: "Все" follow: "Подписки" @@ -1977,19 +2085,57 @@ _dialog: _disabledTimeline: title: "Лента отключена" description: "Ваша текущая роль не позволяет пользоваться этой лентой." +_drivecleaner: + orderBySizeDesc: "Размеры файлов по убыванию" + orderByCreatedAtAsc: "По увеличению даты" _webhookSettings: createWebhook: "Создать вебхук" + modifyWebhook: "Изменить Вебхук" name: "Название" + secret: "Секрет" + trigger: "Условие срабатывания" active: "Вкл." + _events: + follow: "Когда подписались на пользователя" + followed: "Когда на вас подписались" + note: "Когда создали заметку" + reply: "Когда получили ответ на заметку" + renote: "Когда вас репостнули" + reaction: "Когда получили реакцию" + mention: "Когда вас упоминают" + _systemEvents: + abuseReport: "Когда приходит жалоба" + abuseReportResolved: "Когда разрешается жалоба" + userCreated: "Когда создан пользователь" + deleteConfirm: "Вы уверены, что хотите удалить этот Вебхук?" _abuseReport: _notificationRecipient: _recipientType: mail: "Электронная почта" + webhook: "Вебхук" + _captions: + webhook: "Отправить уведомление Системному Вебхуку при получении или разрешении жалоб." + notifiedWebhook: "Используемый Вебхук" _moderationLogTypes: suspend: "Заморозить" addCustomEmoji: "Добавлено эмодзи" updateCustomEmoji: "Изменено эмодзи" deleteCustomEmoji: "Удалено эмодзи" + deleteDriveFile: "Файл удалён" resetPassword: "Сброс пароля:" + createInvitation: "Создать код приглашения" + createSystemWebhook: "Создать Системный Вебхук" + updateSystemWebhook: "Обновить Системый Вебхук" + deleteSystemWebhook: "Удалить Системный Вебхук" +_fileViewer: + url: "Ссылка" + attachedNotes: "Закреплённые заметки" +_dataSaver: + _code: + title: "Подсветка кода" +_hemisphere: + N: "Северное полушарие" + S: "Южное полушарие" + caption: "Используется для некоторых настроек клиента для определения сезона." _reversi: total: "Всего" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 41f894919615..eb1675bdb078 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -454,7 +454,6 @@ uiLanguage: "Jazyk používateľského prostredia" aboutX: "O {x}" emojiStyle: "Štýl emoji" native: "Natívne" -disableDrawer: "Nepoužívať šuflíkové menu" showNoteActionsOnlyHover: "Ovládacie prvky poznámky sa zobrazujú len po nabehnutí myši" noHistory: "Žiadna história" signinHistory: "História prihlásení" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 63f27934283b..f5d29a2ce51d 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -509,7 +509,6 @@ uiLanguage: "ภาษาอินเทอร์เฟซผู้ใช้ง aboutX: "เกี่ยวกับ {x}" emojiStyle: "สไตล์ของเอโมจิ" native: "ภาษาแม่" -disableDrawer: "ไม่แสดงเมนูในรูปแบบลิ้นชัก" showNoteActionsOnlyHover: "แสดงการดำเนินการโน้ตเมื่อโฮเวอร์(วางเมาส์เหนือ)เท่านั้น" showReactionsCount: "แสดงจำนวนรีแอกชั่นในโน้ต" noHistory: "ไม่มีประวัติ" @@ -887,7 +886,7 @@ accountDeletionInProgress: "กำลังดำเนินการลบบ usernameInfo: "ชื่อที่ระบุบัญชีของคุณจากผู้อื่นในเซิร์ฟเวอร์นี้ คุณสามารถใช้ตัวอักษร (a~z, A~Z), ตัวเลข (0~9) หรือขีดล่าง (_) ชื่อผู้ใช้ไม่สามารถเปลี่ยนแปลงได้ในภายหลัง" aiChanMode: "โหมด Ai " devMode: "โหมดนักพัฒนา" -keepCw: "เก็บคำเตือนเนื้อหา" +keepCw: "คงการเตือนเนื้อหาไว้" pubSub: "บัญชี Pub/Sub" lastCommunication: "การสื่อสารครั้งสุดท้ายล่าสุด" resolved: "คลี่คลายแล้ว" @@ -1034,15 +1033,15 @@ achievements: "ความสำเร็จ" gotInvalidResponseError: "การตอบสนองเซิร์ฟเวอร์ไม่ถูกต้อง" gotInvalidResponseErrorDescription: "เซิร์ฟเวอร์อาจไม่สามารถเข้าถึงได้หรืออาจจะกำลังอยู่ในระหว่างปรับปรุง กรุณาลองใหม่อีกครั้งในภายหลังนะคะ" thisPostMayBeAnnoying: "โน้ตนี้อาจจะเป็นการรบกวนผู้อื่นนะคะ" -thisPostMayBeAnnoyingHome: "โพสต์ไปยังไทม์ไลน์หลัก" -thisPostMayBeAnnoyingCancel: "เลิก" -thisPostMayBeAnnoyingIgnore: "โพสต์ยังไงก็แล้วแต่" +thisPostMayBeAnnoyingHome: "โพสต์ลงไทม์ไลน์หลักเท่านั้น" +thisPostMayBeAnnoyingCancel: "ยกเลิก" +thisPostMayBeAnnoyingIgnore: "โพสต์ไปเลย ไม่ต้องปรับการมองเห็น" collapseRenotes: "ยุบรีโน้ตที่คุณเคยเห็นแล้ว" collapseRenotesDescription: "พับย่อโน้ตที่เคยตอบสนองหรือรีโน้ตแล้ว" internalServerError: "เซิร์ฟเวอร์ภายในเกิดข้อผิดพลาด" internalServerErrorDescription: "เกิดข้อผิดพลาดที่ไม่คาดคิดภายในเซิร์ฟเวอร์" copyErrorInfo: "คัดลอกรายละเอียดข้อผิดพลาด" -joinThisServer: "ลงทะเบียนบนเซิร์ฟเวอร์นี้" +joinThisServer: "ลงทะเบียนในเซิร์ฟเวอร์นี้" exploreOtherServers: "มองหาเซิร์ฟเวอร์อื่น" letsLookAtTimeline: "มาดูไทม์ไลน์กัน" disableFederationConfirm: "ปิดใช้งานสหพันธ์เลยใช่ไหม?" @@ -1105,7 +1104,7 @@ vertical: "แนวตั้ง" horizontal: "แนวนอน" position: "ตำแหน่ง" serverRules: "กฎของเซิร์ฟเวอร์" -pleaseConfirmBelowBeforeSignup: "หากต้องการลงทะเบียนบนเซิร์ฟเวอร์นี้ คุณต้องตรวจสอบและยอมรับสิ่งต่อไปนี้" +pleaseConfirmBelowBeforeSignup: "หากต้องการลงทะเบียนในเซิร์ฟเวอร์นี้ คุณต้องตรวจสอบและยอมรับสิ่งต่อไปนี้" pleaseAgreeAllToContinue: "คุณต้องยอมรับทุกช่องตรงด้านบนเพื่อดำเนินการต่อค่ะ" continue: "ดำเนินการต่อ" preservedUsernames: "ชื่อผู้ใช้ที่สงวนไว้" @@ -1361,9 +1360,9 @@ _initialTutorial: localOnly: "การโพสต์ด้วย flag นี้จะไม่รวมโน้ตไปยังเซิร์ฟเวอร์อื่น ผู้ใช้บนเซิร์ฟเวอร์อื่นจะไม่สามารถดูโน้ตเหล่านี้ได้โดยตรง โดยไม่คำนึงถึงการตั้งค่าการแสดงผลข้างต้น" _cw: title: "คำเตือนเกี่ยวกับเนื้อหา" - description: "เนื้อหาที่เขียนด้วย “คำอธิบายประกอบ” จะแสดงแทนข้อความหลัก คลิก “ดูเพิ่มเติม” เพื่อแสดงข้อความเต็ม" + description: "เนื้อหาที่เขียนใน “คำอธิบายประกอบ” จะแสดงแทนเนื้อหาหลัก ต้องคลิก “ดูเพิ่มเติม” เพื่อให้เนื้อหาหลักแสดง" _exampleNote: - cw: "นี่อาจจะทำให้คุณหิวอย่างแน่นอน!" + cw: " ห้ามดู ระวังหิว" note: "เพิ่งไปกินโดนัทเคลือบช็อคโกแลตมา 🍩😋" useCases: "ใช้สิ่งนี้เพื่อระบุโน้ตที่ต้องตามแนวทางปฏิบัติของเซิร์ฟเวอร์ หรือเพื่อควบคุมการสปอยล์และข้อความที่ละเอียดอ่อนด้วยตนเอง" _howToMakeAttachmentsSensitive: @@ -1479,15 +1478,15 @@ _achievements: title: "มือใหม่ III" description: "เข้าสู่ระบบเป็นเวลารวม 15 วัน" _login30: - title: "มิสคิสท์ I" + title: "มิสคิสต์ I" description: "เข้าสู่ระบบเป็นเวลารวม 30 วัน" _login60: - title: "มิสคิสท์ II" + title: "มิสคิสต์ II" description: "เข้าสู่ระบบเป็นเวลารวม 60 วัน" _login100: - title: "มิสคิสท์ III" + title: "มิสคิสต์ III" description: "เข้าสู่ระบบเป็นเวลารวม 100 วัน" - flavor: "มิสคิสต์หัวรุนแรง" + flavor: "Violent Misskist (ทำไมเหมือนชื่อหนังสักเรื่องจังเลยนะ)" _login200: title: "ลูกค้าประจำ I" description: "เข้าสู่ระบบเป็นเวลารวม 200 วัน" @@ -2155,7 +2154,7 @@ _widgets: serverMetric: "ตัวชี้วัดเซิร์ฟเวอร์" aiscript: " คอนโซล AiScript" aiscriptApp: "แอป AiScript" - aichan: "ไอ" + aichan: "藍 (ไอ)" userList: "รายชื่อผู้ใช้" _userList: chooseList: "เลือกรายชื่อ" @@ -2197,7 +2196,7 @@ _visibility: followersDescription: "เฉพาะผู้ติดตามเท่านั้นที่มองเห็นได้" specified: "ไดเร็ค" specifiedDescription: "ทำให้มองเห็นได้เฉพาะผู้ใช้ที่ระบุเท่านั้น" - disableFederation: "ไม่มีสหพันธ์" + disableFederation: "การปิดใช้งานสหพันธ์" disableFederationDescription: "อย่าส่งข้อมูลไปยังเซิร์ฟเวอร์อื่น" _postForm: replyPlaceholder: "ตอบกลับโน้ตนี้..." @@ -2426,6 +2425,7 @@ _webhookSettings: modifyWebhook: "แก้ไข Webhook" name: "ชื่อ" secret: "ความลับ" + trigger: "ทริกเกอร์" active: "เปิดใช้งาน" _events: follow: "เมื่อกำลังติดตามผู้ใช้" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 36d741d30e4a..e51156ce220e 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -452,7 +452,6 @@ language: "Мова" uiLanguage: "Мова інтерфейсу" aboutX: "Про {x}" native: "місцевий" -disableDrawer: "Не використовувати висувні меню" noHistory: "Історія порожня" signinHistory: "Історія входів" enableAdvancedMfm: "Увімкнути розширений MFM" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index ee4ab83ce7e1..cf2e5f2fe789 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -471,7 +471,6 @@ uiLanguage: "Interfeys tili" aboutX: "{x} haqida" emojiStyle: "Emoji ko'rinishi" native: "Mahalliy" -disableDrawer: "Slayd menyusidan foydalanmang" showNoteActionsOnlyHover: "Eslatma amallarini faqat sichqonchani olib borganda ko‘rsatish" noHistory: "Tarix yo'q" signinHistory: "kirish tarixi" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index aadbf8b16f22..f3979bbd3c53 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -486,7 +486,6 @@ uiLanguage: "Ngôn ngữ giao diện" aboutX: "Giới thiệu {x}" emojiStyle: "Kiểu cách Emoji" native: "Bản xứ" -disableDrawer: "Không dùng menu thanh bên" showNoteActionsOnlyHover: "Chỉ hiển thị các hành động ghi chú khi di chuột" noHistory: "Không có dữ liệu" signinHistory: "Lịch sử đăng nhập" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 1deb0effc394..0d76361d6fb6 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -334,6 +334,7 @@ renameFolder: "重命名文件夹" deleteFolder: "删除文件夹" folder: "文件夹" addFile: "添加文件" +showFile: "显示文件" emptyDrive: "网盘中无文件" emptyFolder: "此文件夹中无文件" unableToDelete: "无法删除" @@ -509,7 +510,9 @@ uiLanguage: "显示语言" aboutX: "关于 {x}" emojiStyle: "表情符号的样式" native: "原生" -disableDrawer: "不显示抽屉菜单" +menuStyle: "菜单样式" +style: "样式" +popup: "弹窗" showNoteActionsOnlyHover: "仅在悬停时显示帖子操作" showReactionsCount: "显示帖子的回应数" noHistory: "没有历史记录" @@ -592,6 +595,8 @@ ascendingOrder: "升序" descendingOrder: "降序" scratchpad: "AiScript 控制台" scratchpadDescription: "AiScript 控制台为 AiScript 提供了实验环境。您可以编写代码与 Misskey 交互,运行并查看结果。" +uiInspector: "UI 检查器" +uiInspectorDescription: "查看所有内存中由 UI 组件生成出的实例。UI 组件由 UI:C 系列函数所生成。" output: "输出" script: "脚本" disablePagesScript: "禁用页面脚本" @@ -1263,6 +1268,15 @@ confirmWhenRevealingSensitiveMedia: "显示敏感内容前需要确认" sensitiveMediaRevealConfirm: "这是敏感内容。是否显示?" createdLists: "已创建的列表" createdAntennas: "已创建的天线" +fromX: "从 {x}" +genEmbedCode: "生成嵌入代码" +noteOfThisUser: "此用户的帖子" +clipNoteLimitExceeded: "无法再往此便签内添加更多帖子" +performance: "性能" +signinWithPasskey: "使用通行密钥登录" +unknownWebAuthnKey: "此通行密钥未注册。" +passkeyVerificationFailed: "验证通行密钥失败。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。" _delivery: status: "投递状态" stop: "停止投递" @@ -1397,6 +1411,7 @@ _serverSettings: fanoutTimelineDescription: "当启用时,可显著提高获取各种时间线时的性能,并减轻数据库的负荷。但是相对的 Redis 的内存使用量将会增加。如果服务器的内存不是很大,又或者运行不稳定的话可以把它关掉。" fanoutTimelineDbFallback: "回退到数据库" fanoutTimelineDbFallbackDescription: "当启用时,若时间线未被缓存,则将额外查询数据库。禁用该功能可通过不执行回退处理进一步减少服务器负载,但会限制可检索的时间线范围。" + reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。" inquiryUrl: "联络地址" inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" _accountMigration: @@ -1598,7 +1613,7 @@ _achievements: _postedAt0min0sec: title: "报时" description: "在 0 点发布一篇帖子" - flavor: "嘣 嘣 嘣 Biu——!" + flavor: "报时信号最后一响,零点整" _selfQuote: title: "自我引用" description: "引用了自己的帖子" @@ -1647,8 +1662,8 @@ _achievements: flavor: "今年也请对本服务器多多指教!" _cookieClicked: title: "点击饼干小游戏" - description: "点击了可疑的饼干" - flavor: "是不是软件有问题?" + description: "点击了饼干" + flavor: "用错软件了?" _brainDiver: title: "Brain Diver" description: "发布了包含 Brain Diver 链接的帖子" @@ -1665,6 +1680,7 @@ _achievements: _bubbleGameDoubleExplodingHead: title: "两个🤯" description: "你合成出了2个游戏里最大的Emoji" + flavor: "大约能 装满 这些便当盒 🤯 🤯 (比划)" _role: new: "创建角色" edit: "编辑角色" @@ -1729,6 +1745,11 @@ _role: canSearchNotes: "是否可以搜索帖子" canUseTranslator: "使用翻译功能" avatarDecorationLimit: "可添加头像挂件的最大个数" + canImportAntennas: "允许导入天线" + canImportBlocking: "允许导入拉黑列表" + canImportFollowing: "允许导入关注列表" + canImportMuting: "允许导入屏蔽列表" + canImportUserLists: "允许导入用户列表" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -2315,6 +2336,7 @@ _pages: eyeCatchingImageSet: "设置封面图片" eyeCatchingImageRemove: "删除封面图片" chooseBlock: "添加块" + enterSectionTitle: "输入会话标题" selectType: "选择类型" contentBlocks: "内容" inputBlocks: "输入" @@ -2360,6 +2382,7 @@ _notification: renotedBySomeUsers: "{n} 人转发了" followedBySomeUsers: "被 {n} 人关注" flushNotification: "重置通知历史" + exportOfXCompleted: "已完成 {x} 个导出" _types: all: "全部" note: "用户的新帖子" @@ -2374,6 +2397,8 @@ _notification: followRequestAccepted: "关注请求已通过" roleAssigned: "授予的角色" achievementEarned: "取得的成就" + exportCompleted: "已完成导出" + test: "测试通知" app: "关联应用的通知" _actions: followBack: "回关" @@ -2440,6 +2465,7 @@ _webhookSettings: abuseReportResolved: "当举报被处理时" userCreated: "当用户被创建时" deleteConfirm: "要删除 webhook 吗?" + testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。" _abuseReport: _notificationRecipient: createRecipient: "新建举报通知" @@ -2498,6 +2524,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "新建了举报通知" updateAbuseReportNotificationRecipient: "更新了举报通知" deleteAbuseReportNotificationRecipient: "删除了举报通知" + deleteAccount: "删除了账户" + deletePage: "删除了页面" + deleteFlash: "删除了 Play" + deleteGalleryPost: "删除了图库稿件" _fileViewer: title: "文件信息" type: "文件类型" @@ -2634,3 +2664,17 @@ _contextMenu: app: "应用" appWithShift: "Shift 键应用" native: "浏览器的用户界面" +_embedCodeGen: + title: "自定义嵌入代码" + header: "显示标题" + autoload: "连续加载(不推荐)" + maxHeight: "最大高度" + maxHeightDescription: "若将最大值设为 0 则不限制最大高度。为防止小工具无限增高,建议设置一下。" + maxHeightWarn: "最大高度限制已禁用(0)。若这不是您想要的效果,请将最大高度设一个值。" + previewIsNotActual: "由于超出了预览画面可显示的范围,因此显示内容会与实际嵌入时有所不同。" + rounded: "圆角" + border: "外边框" + applyToPreview: "应用预览" + generateCode: "生成嵌入代码" + codeGenerated: "已生成代码" + codeGeneratedDescription: "将生成的代码贴到网站上来使用。" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 16dc464e352a..74c03befd120 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -236,6 +236,8 @@ silencedInstances: "被禁言的伺服器" silencedInstancesDescription: "設定要禁言的伺服器主機名稱,以換行分隔。隸屬於禁言伺服器的所有帳戶都將被視為「禁言帳戶」,只能發出「追隨請求」,而且無法提及未追隨的本地帳戶。這不會影響已封鎖的實例。" mediaSilencedInstances: "媒體被禁言的伺服器" mediaSilencedInstancesDescription: "設定您想要對媒體設定禁言的伺服器,以換行符號區隔。來自被媒體禁言的伺服器所屬帳戶的所有檔案都會被視為敏感檔案,且自訂表情符號不能使用。被封鎖的伺服器不受影響。" +federationAllowedHosts: "允許聯邦通訊的伺服器" +federationAllowedHostsDescription: "設定允許聯邦通訊的伺服器主機,以換行符號分隔。" muteAndBlock: "靜音和封鎖" mutedUsers: "被靜音的使用者" blockedUsers: "被封鎖的使用者" @@ -334,6 +336,7 @@ renameFolder: "重新命名資料夾" deleteFolder: "刪除資料夾" folder: "資料夾" addFile: "加入附件" +showFile: "瀏覽文件" emptyDrive: "雲端硬碟為空" emptyFolder: "資料夾為空" unableToDelete: "無法刪除" @@ -509,7 +512,10 @@ uiLanguage: "介面語言" aboutX: "關於{x}" emojiStyle: "表情符號的風格" native: "原生" -disableDrawer: "不顯示下拉式選單" +menuStyle: "選單風格" +style: "風格" +drawer: "側邊欄" +popup: "彈出式視窗" showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" showReactionsCount: "顯示貼文的反應數目" noHistory: "沒有歷史紀錄" @@ -592,6 +598,8 @@ ascendingOrder: "昇冪" descendingOrder: "降冪" scratchpad: "暫存記憶體" scratchpadDescription: "AiScript 控制臺為 AiScript 的實驗環境。您可以在此編寫、執行和確認程式碼與 Misskey 互動的結果。" +uiInspector: "UI 檢查" +uiInspectorDescription: "您可以看到記憶體中存在的 UI 元件實例的清單。 UI 元件由 Ui:C: 系列函數產生。" output: "輸出" script: "腳本" disablePagesScript: "停用頁面的 AiScript 腳本" @@ -1186,7 +1194,7 @@ edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" followingOrFollower: "追隨中或追隨者" -fileAttachedOnly: "顯示包含附件的貼文" +fileAttachedOnly: "只顯示包含附件的貼文" showRepliesToOthersInTimeline: "顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" showRepliesToOthersInTimelineAll: "在時間軸包含追隨中所有人的回覆" @@ -1263,6 +1271,18 @@ confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" sensitiveMediaRevealConfirm: "這是敏感媒體。確定要顯示嗎?" createdLists: "已建立的清單" createdAntennas: "已建立的天線" +fromX: "自 {x}" +genEmbedCode: "產生嵌入程式碼" +noteOfThisUser: "這個使用者的貼文列表" +clipNoteLimitExceeded: "沒辦法在這個摘錄中增加更多貼文了。" +performance: "性能" +modified: "已變更" +discard: "取消" +thereAreNChanges: "有 {n} 處的變更" +signinWithPasskey: "使用密碼金鑰登入" +unknownWebAuthnKey: "未註冊的金鑰。" +passkeyVerificationFailed: "驗證金鑰失敗。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。" _delivery: status: "傳送狀態" stop: "停止發送" @@ -1397,6 +1417,7 @@ _serverSettings: fanoutTimelineDescription: "如果啟用的話,檢索各個時間軸的性能會顯著提昇,資料庫的負荷也會減少。不過,Redis 的記憶體使用量會增加。如果伺服器的記憶體容量比較少或者運行不穩定,可以停用。" fanoutTimelineDbFallback: "資料庫的回退" fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。" + reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。" inquiryUrl: "聯絡表單網址" inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" _accountMigration: @@ -1730,6 +1751,11 @@ _role: canSearchNotes: "可否搜尋貼文" canUseTranslator: "使用翻譯功能" avatarDecorationLimit: "頭像裝飾的最大設置量" + canImportAntennas: "允許匯入天線" + canImportBlocking: "允許匯入封鎖名單" + canImportFollowing: "允許匯入跟隨名單" + canImportMuting: "允許匯入靜音名單" + canImportUserLists: "允許匯入清單" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -1967,7 +1993,7 @@ _soundSettings: driveFileTypeWarnDescription: "請選擇音效檔案" driveFileDurationWarn: "音效太長了" driveFileDurationWarnDescription: "使用長音效檔可能會影響 Misskey 的使用體驗。仍要使用此檔案嗎?" - driveFileError: "無法載入語音。請更改設定" + driveFileError: "無法載入語音。請變更設定" _ago: future: "未來" justNow: "剛剛" @@ -2224,6 +2250,9 @@ _profile: changeBanner: "變更橫幅圖像" verifiedLinkDescription: "如果輸入包含您個人資料的網站 URL,欄位旁邊將出現驗證圖示。" avatarDecorationMax: "最多可以設置 {max} 個裝飾。" + followedMessage: "被追隨時的訊息" + followedMessageDescription: "可以設定被追隨時顯示給對方的訊息。" + followedMessageDescriptionForLockedAccount: "如果追隨是需要審核的話,在允許追隨請求之後顯示。" _exportOrImport: allNotes: "所有貼文" favoritedNotes: "「我的最愛」貼文" @@ -2316,6 +2345,7 @@ _pages: eyeCatchingImageSet: "設定封面影像" eyeCatchingImageRemove: "刪除封面影像" chooseBlock: "新增方塊" + enterSectionTitle: "輸入區段的標題" selectType: "選擇類型" contentBlocks: "內容" inputBlocks: "輸入" @@ -2361,6 +2391,7 @@ _notification: renotedBySomeUsers: "{n}人做了轉發" followedBySomeUsers: "被{n}人追隨了" flushNotification: "重置通知歷史紀錄" + exportOfXCompleted: "{x} 的匯出已完成。" _types: all: "全部 " note: "使用者的最新貼文" @@ -2375,6 +2406,8 @@ _notification: followRequestAccepted: "追隨請求已接受" roleAssigned: "已授予角色" achievementEarned: "獲得成就" + exportCompleted: "已完成匯出。" + test: "通知測試" app: "應用程式通知" _actions: followBack: "追隨回去" @@ -2439,7 +2472,9 @@ _webhookSettings: _systemEvents: abuseReport: "當使用者檢舉時" abuseReportResolved: "當處理了使用者的檢舉時" + userCreated: "使用者被新增時" deleteConfirm: "請問是否要刪除 Webhook?" + testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。" _abuseReport: _notificationRecipient: createRecipient: "新增接收檢舉的通知對象" @@ -2498,6 +2533,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "建立接收檢舉的通知對象" updateAbuseReportNotificationRecipient: "更新接收檢舉的通知對象" deleteAbuseReportNotificationRecipient: "刪除接收檢舉的通知對象" + deleteAccount: "刪除帳戶" + deletePage: "刪除頁面" + deleteFlash: "刪除 Play" + deleteGalleryPost: "刪除相簿的貼文" _fileViewer: title: "檔案詳細資訊" type: "檔案類型 " @@ -2632,4 +2671,19 @@ _mediaControls: _contextMenu: title: "內容功能表" app: "應用程式" + appWithShift: "Shift 鍵應用程式" native: "瀏覽器的使用者介面" +_embedCodeGen: + title: "自訂嵌入程式碼" + header: "檢視標頭 " + autoload: "自動繼續載入(不建議)" + maxHeight: "最大高度" + maxHeightDescription: "設定為 0 時代表沒有最大值。請指定某個值以避免小工具持續在縱向延伸。" + maxHeightWarn: "最大高度限制已停用(0)。如果這個變更不是您想要的,請將最大高度設定為某個值。" + previewIsNotActual: "由於超出了預覽畫面可顯示的範圍,因此顯示內容會與實際嵌入時有所不同。" + rounded: "圓角" + border: "給外框加上邊框" + applyToPreview: "反映在預覽中" + generateCode: "建立嵌入程式碼" + codeGenerated: "已產生程式碼" + codeGeneratedDescription: "請將產生的程式碼貼到您的網站上。" diff --git a/package.json b/package.json index 581a2603a193..95d908dddc34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2024.7.0+neko-rc", + "version": "2024.9.0+neko-rc", "codename": "nasubi", "repository": { "type": "git", @@ -8,7 +8,9 @@ }, "packageManager": "pnpm@9.6.0", "workspaces": [ + "packages/frontend-shared", "packages/frontend", + "packages/frontend-embed", "packages/backend", "packages/sw", "packages/misskey-js", @@ -22,6 +24,13 @@ "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", + "update-misskey-js-and-build": "pnpm build-misskey-js-with-types && pnpm -r build && pnpm build-assets", + "update-mjs": "pnpm update-misskey-js-and-build", + + "backend:build": "pnpm build-pre && pnpm --filter backend build", + "frontend:build": "pnpm build-pre && pnpm build-storybook && pnpm --filter frontend build && pnpm build-assets", + "misskey-js:build": "pnpm build-misskey-js-with-types", + "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", @@ -35,6 +44,7 @@ "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", + "e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "test": "pnpm -r test", @@ -53,26 +63,26 @@ "fast-glob": "3.3.2", "ignore-walk": "6.0.5", "js-yaml": "4.1.0", - "postcss": "8.4.40", + "postcss": "8.4.47", "tar": "6.2.1", - "terser": "5.31.3", - "typescript": "5.5.4", - "esbuild": "0.23.0", + "terser": "5.33.0", + "typescript": "5.6.2", + "esbuild": "0.23.1", "glob": "11.0.0" }, "devDependencies": { - "@misskey-dev/eslint-plugin": "2.0.2", + "@misskey-dev/eslint-plugin": "github:nekoplanet/eslint-plugin-misskey", "@types/node": "20.14.12", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", + "@typescript-eslint/eslint-plugin": "8.7.0", + "@typescript-eslint/parser": "8.7.0", "cross-env": "7.0.3", - "cypress": "13.13.1", - "eslint": "9.8.0", - "globals": "15.8.0", + "cypress": "13.14.2", + "eslint": "^9.10", + "globals": "15.9.0", "ncp": "2.0.0", - "start-server-and-test": "2.0.4" + "start-server-and-test": "2.0.8" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.4.0" } -} +} \ No newline at end of file diff --git a/packages/backend/assets/embed.js b/packages/backend/assets/embed.js new file mode 100644 index 000000000000..24fccc1b6c1f --- /dev/null +++ b/packages/backend/assets/embed.js @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: MIT + */ +//@ts-check +(() => { + /** @type {NodeListOf} */ + const els = document.querySelectorAll('iframe[data-misskey-embed-id]'); + + window.addEventListener('message', function (event) { + els.forEach((el) => { + if (event.source !== el.contentWindow) { + return; + } + + const id = el.dataset.misskeyEmbedId; + + if (event.data.type === 'misskey:embed:ready') { + el.contentWindow?.postMessage({ + type: 'misskey:embedParent:registerIframeId', + payload: { + iframeId: id, + } + }, '*'); + } + if (event.data.type === 'misskey:embed:changeHeight' && event.data.iframeId === id) { + el.style.height = event.data.payload.height + 'px'; + } + }); + }); +})(); diff --git a/packages/backend/migration/1720853122058-RevRevertNoteEdit.js b/packages/backend/migration/1720853122058-RevRevertNoteEdit.js new file mode 100644 index 000000000000..b7b97f74c200 --- /dev/null +++ b/packages/backend/migration/1720853122058-RevRevertNoteEdit.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RevRevertNoteEdit1720853122058 { + name = 'RevRevertNoteEdit1720853122058' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD IF NOT EXISTS "updatedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "note" ADD IF NOT EXISTS "updatedAtHistory" TIMESTAMP WITH TIME ZONE ARRAY`); + await queryRunner.query(`ALTER TABLE "note" ADD IF NOT EXISTS "noteEditHistory" character varying array`) + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN IF EXISTS "updatedAt"`); + await queryRunner.query(`ALTER TABLE "note" DROP IF EXISTS "updatedAtHistory"`); + await queryRunner.query(`ALTER TABLE "note" DROP IF EXISTS "noteEditHistory"`) + } + +} diff --git a/packages/backend/migration/1723585019082-noteCreatedAtMissing.js b/packages/backend/migration/1723585019082-noteCreatedAtMissing.js new file mode 100644 index 000000000000..30583804682b --- /dev/null +++ b/packages/backend/migration/1723585019082-noteCreatedAtMissing.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +// 1723585019082-noteCreatedAtMissing.js +export class NoteCreatedAtMissing1723585019082 { + name = 'NoteCreatedAtMissing1723585019082' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD IF NOT EXISTS "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()`); + } + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN IF EXISTS "createdAt"`); + } + +} diff --git a/packages/backend/migration/1723944246767-followedMessage.js b/packages/backend/migration/1723944246767-followedMessage.js new file mode 100644 index 000000000000..fc9ad1cb8538 --- /dev/null +++ b/packages/backend/migration/1723944246767-followedMessage.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FollowedMessage1723944246767 { + name = 'FollowedMessage1723944246767'; + + async up(queryRunner) { + await queryRunner.query('ALTER TABLE "user_profile" ADD "followedMessage" character varying(256)'); + } + + async down(queryRunner) { + await queryRunner.query('ALTER TABLE "user_profile" DROP COLUMN "followedMessage"'); + } +} diff --git a/packages/backend/migration/1724072711475-NoteEdit.js b/packages/backend/migration/1724072711475-NoteEdit.js new file mode 100644 index 000000000000..895b7d141f5b --- /dev/null +++ b/packages/backend/migration/1724072711475-NoteEdit.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteEdit1724072711475 { + name = 'NoteEdit1724072711475' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD IF NOT EXISTS "updatedAt" TIMESTAMP WITH TIME ZONE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN IF EXISTS "updatedAt"`); + } +} diff --git a/packages/backend/migration/1726804538569-reactions-buffering.js b/packages/backend/migration/1726804538569-reactions-buffering.js new file mode 100644 index 000000000000..bc19e9cc8aa6 --- /dev/null +++ b/packages/backend/migration/1726804538569-reactions-buffering.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ReactionsBuffering1726804538569 { + name = 'ReactionsBuffering1726804538569' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableReactionsBuffering" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableReactionsBuffering"`); + } +} diff --git a/packages/backend/migration/1726885432089-remoteAvaterDecoration.js b/packages/backend/migration/1726885432089-remoteAvaterDecoration.js new file mode 100644 index 000000000000..31cc3bded235 --- /dev/null +++ b/packages/backend/migration/1726885432089-remoteAvaterDecoration.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RemoteAvaterDecoration1726885432089 { + name = 'RemoteAvaterDecoration1726885432089' + + async up(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "remoteId" varchar(32)`); + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" varchar(128)`); + } + + async down(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "remoteId"`); + } +} diff --git a/packages/backend/migration/1727491883993-user-score.js b/packages/backend/migration/1727491883993-user-score.js new file mode 100644 index 000000000000..7292d5363c53 --- /dev/null +++ b/packages/backend/migration/1727491883993-user-score.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserScore1727491883993 { + name = 'UserScore1727491883993' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "score" integer NOT NULL DEFAULT '0'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "score"`); + } +} diff --git a/packages/backend/migration/1727512908322-meta-federation.js b/packages/backend/migration/1727512908322-meta-federation.js new file mode 100644 index 000000000000..52c24df4f76a --- /dev/null +++ b/packages/backend/migration/1727512908322-meta-federation.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MetaFederation1727512908322 { + name = 'MetaFederation1727512908322' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "federation" character varying(128) NOT NULL DEFAULT 'all'`); + await queryRunner.query(`ALTER TABLE "meta" ADD "federationHosts" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "federationHosts"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "federation"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index f497610af902..c8b91e6034ae 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -67,24 +67,24 @@ "dependencies": { "@aws-sdk/client-s3": "3.620.0", "@aws-sdk/lib-storage": "3.620.0", - "@bull-board/api": "5.21.1", - "@bull-board/fastify": "5.21.1", - "@bull-board/ui": "5.21.1", - "@discordapp/twemoji": "15.0.3", - "@fastify/accepts": "4.3.0", - "@fastify/cookie": "9.3.1", - "@fastify/cors": "9.0.1", - "@fastify/express": "3.0.0", - "@fastify/http-proxy": "9.5.0", - "@fastify/multipart": "8.3.0", - "@fastify/static": "7.0.4", - "@fastify/view": "9.1.0", + "@bull-board/api": "6.0.0", + "@bull-board/fastify": "6.0.0", + "@bull-board/ui": "6.0.0", + "@discordapp/twemoji": "15.1.0", + "@fastify/accepts": "5.0.0", + "@fastify/cookie": "10.0.0", + "@fastify/cors": "10.0.0", + "@fastify/express": "4.0.0", + "@fastify/http-proxy": "10.0.0", + "@fastify/multipart": "9.0.0", + "@fastify/static": "8.0.0", + "@fastify/view": "10.0.0", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.1.0", - "@napi-rs/canvas": "^0.1.53", - "@nestjs/common": "10.3.10", - "@nestjs/core": "10.3.10", - "@nestjs/testing": "10.3.10", + "@napi-rs/canvas": "0.1.56", + "@nestjs/common": "10.4.3", + "@nestjs/core": "10.4.3", + "@nestjs/testing": "10.4.3", "@peertube/http-signature": "1.7.0", "@sentry/node": "8.20.0", "@sentry/profiling-node": "8.20.0", @@ -100,8 +100,8 @@ "async-mutex": "0.5.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", - "body-parser": "1.20.2", - "bullmq": "5.10.4", + "body-parser": "1.20.3", + "bullmq": "5.13.2", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.3.0", @@ -112,27 +112,28 @@ "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fastify": "4.28.1", - "fastify-raw-body": "4.3.0", + "fastify": "5.0.0", + "fastify-raw-body": "5.0.0", "feed": "4.2.2", - "file-type": "19.3.0", + "file-type": "19.5.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.0", "got": "14.4.2", - "happy-dom": "10.0.3", + "happy-dom": "15.7.4", "hpagent": "1.2.0", "htmlescape": "1.1.1", "http-link-header": "1.1.3", "ioredis": "5.4.1", - "ip-cidr": "4.0.1", + "ip-cidr": "4.0.2", "ipaddr.js": "2.2.0", - "is-svg": "5.0.1", + "is-svg": "5.1.0", "js-yaml": "4.1.0", "jsdom": "24.1.1", "json5": "2.2.3", "jsonld": "8.3.2", "jsrsasign": "11.1.0", - "meilisearch": "0.41.0", + "meilisearch": "0.42.0", + "juice": "11.0.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", @@ -142,24 +143,24 @@ "nanoid": "5.0.7", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.14", - "nsfwjs": "2.4.2", + "nodemailer": "6.9.15", + "nsfwjs": "3.0.0", "oauth": "0.10.0", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.3.1", + "otpauth": "9.3.2", "parse5": "7.1.2", - "pg": "8.12.0", + "pg": "8.13.0", "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.3", "punycode": "2.3.1", - "qrcode": "1.5.3", + "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.21.3", + "re2": "1.21.4", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", @@ -167,17 +168,17 @@ "rxjs": "7.8.1", "sanitize-html": "2.13.0", "secure-json-parse": "2.7.0", - "sharp": "0.33.4", + "sharp": "0.33.5", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.22.11", + "systeminformation": "5.23.5", "tinycolor2": "1.6.0", "tmp": "0.2.3", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", "typeorm": "0.3.20", - "typescript": "5.5.4", + "typescript": "5.6.2", "ulid": "2.3.0", "vary": "1.1.2", "web-push": "3.6.7", @@ -186,19 +187,19 @@ }, "devDependencies": { "@jest/globals": "29.7.0", - "@nestjs/platform-express": "10.3.10", + "@nestjs/platform-express": "10.4.3", "@simplewebauthn/types": "10.0.0", "@swc/jest": "0.2.36", "@types/accepts": "1.3.7", "@types/archiver": "6.0.2", "@types/bcryptjs": "2.4.6", "@types/body-parser": "1.19.5", - "@types/color-convert": "2.0.3", + "@types/color-convert": "2.0.4", "@types/content-disposition": "0.5.8", - "@types/fluent-ffmpeg": "2.1.24", + "@types/fluent-ffmpeg": "2.1.26", "@types/htmlescape": "1.1.3", "@types/http-link-header": "1.0.7", - "@types/jest": "29.5.12", + "@types/jest": "29.5.13", "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/jsonld": "1.5.15", @@ -206,18 +207,18 @@ "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", "@types/node": "20.14.12", - "@types/nodemailer": "6.4.15", + "@types/nodemailer": "6.4.16", "@types/oauth": "0.9.5", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.11.6", + "@types/pg": "8.11.10", "@types/pug": "2.0.10", "@types/punycode": "2.1.4", "@types/qrcode": "1.5.5", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.11.0", + "@types/sanitize-html": "2.13.0", "@types/semver": "7.5.8", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", @@ -225,17 +226,17 @@ "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.3", - "@types/ws": "8.5.11", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", + "@types/ws": "8.5.12", + "@typescript-eslint/eslint-plugin": "8.7.0", + "@typescript-eslint/parser": "8.7.0", "aws-sdk-client-mock": "4.0.1", "cross-env": "7.0.3", - "eslint-plugin-import": "2.29.1", - "execa": "9.3.0", + "eslint-plugin-import": "2.31.0", + "execa": "9.4.0", "fkill": "9.0.0", "jest": "29.7.0", "jest-mock": "29.7.0", - "nodemon": "3.1.4", + "nodemon": "3.1.7", "pid-port": "1.0.0", "simple-oauth2": "5.1.0" } diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 09971e8ca022..6ae8ccfbb32c 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -13,6 +13,8 @@ import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import { allSettled } from './misc/promise-tracker.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; +import { MiMeta } from '@/models/Meta.js'; +import { GlobalEvents } from './core/GlobalEventService.js'; const $config: Provider = { provide: DI.config, @@ -78,11 +80,76 @@ const $redisForTimelines: Provider = { inject: [DI.config], }; +const $redisForReactions: Provider = { + provide: DI.redisForReactions, + useFactory: (config: Config) => { + return new Redis.Redis(config.redisForReactions); + }, + inject: [DI.config], +}; + +const $meta: Provider = { + provide: DI.meta, + useFactory: async (db: DataSource, redisForSub: Redis.Redis) => { + const meta = await db.transaction(async transactionalEntityManager => { + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + const metas = await transactionalEntityManager.find(MiMeta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + return meta; + } else { + // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う + const saved = await transactionalEntityManager + .upsert( + MiMeta, + { + id: 'x', + }, + ['id'], + ) + .then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0])); + + return saved; + } + }); + + async function onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'metaUpdated': { + for (const key in body.after) { + (meta as any)[key] = (body.after as any)[key]; + } + meta.proxyAccount = null; // joinなカラムは通常取ってこないので + break; + } + default: + break; + } + } + } + + redisForSub.on('message', onMessage); + + return meta; + }, + inject: [DI.db, DI.redisForSub], +}; + @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines], - exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule], + providers: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions], + exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @@ -91,6 +158,7 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, + @Inject(DI.redisForReactions) private redisForReactions: Redis.Redis, ) { } public async dispose(): Promise { @@ -103,6 +171,7 @@ export class GlobalModule implements OnApplicationShutdown { this.redisForPub.disconnect(), this.redisForSub.disconnect(), this.redisForTimelines.disconnect(), + this.redisForReactions.disconnect(), ]); } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 3e5a1e81cd70..e60de90c2abb 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -49,6 +49,7 @@ type Source = { redisForPubsub?: RedisOptionsSource; redisForJobQueue?: RedisOptionsSource; redisForTimelines?: RedisOptionsSource; + redisForReactions?: RedisOptionsSource; meilisearch?: { host: string; port: string; @@ -129,11 +130,19 @@ export type Config = { index: string; scope?: 'local' | 'global' | string[]; } | undefined; + skebStatus: { + method: string; + endpoint: string; + headers: { [x: string]: string }; + parameters: { [x: string]: string }; + userIdParameterName: string; + roleId: string; + } | undefined; proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; allowedPrivateNetworks: string[] | undefined; - maxFileSize: number | undefined; + maxFileSize: number; clusterLimit: number | undefined; id: string; outgoingAddress: string | undefined; @@ -160,8 +169,10 @@ export type Config = { authUrl: string; driveUrl: string; userAgent: string; - clientEntry: string; - clientManifestExists: boolean; + frontendEntry: string; + frontendManifestExists: boolean; + frontendEmbedEntry: string; + frontendEmbedManifestExists: boolean; mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; @@ -169,6 +180,7 @@ export type Config = { redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource; + redisForReactions: RedisOptions & RedisOptionsSource; sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; sentryForFrontend: { options: Partial } | undefined; perChannelMaxNoteCacheCount: number; @@ -196,10 +208,16 @@ const path = process.env.MISSKEY_CONFIG_YML export function loadConfig(): Config { const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); - const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json'); - const clientManifest = clientManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8')) + + const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); + const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); + const frontendManifest = frontendManifestExists ? + JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; + const frontendEmbedManifest = frontendEmbedManifestExists ? + JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) + : { 'src/boot.ts': { file: 'src/boot.ts' } }; + const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); @@ -243,6 +261,7 @@ export function loadConfig(): Config { redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, + redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis, sentryForBackend: config.sentryForBackend, sentryForFrontend: config.sentryForFrontend, id: config.id, @@ -250,7 +269,7 @@ export function loadConfig(): Config { proxySmtp: config.proxySmtp, proxyBypassHosts: config.proxyBypassHosts, allowedPrivateNetworks: config.allowedPrivateNetworks, - maxFileSize: config.maxFileSize, + maxFileSize: config.maxFileSize ?? 262144000, clusterLimit: config.clusterLimit, outgoingAddress: config.outgoingAddress, outgoingAddressFamily: config.outgoingAddressFamily, @@ -270,12 +289,15 @@ export function loadConfig(): Config { config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator : null, userAgent: `Misskey/${version} (${config.url})`, - clientEntry: clientManifest['src/_boot_.ts'], - clientManifestExists: clientManifestExists, + frontendEntry: frontendManifest['src/_boot_.ts'], + frontendManifestExists: frontendManifestExists, + frontendEmbedEntry: frontendEmbedManifest['src/boot.ts'], + frontendEmbedManifestExists: frontendEmbedManifestExists, perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), pidFile: config.pidFile, + skebStatus: undefined, }; } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index a238f4973a95..e3a61861f425 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -8,6 +8,8 @@ export const MAX_NOTE_TEXT_LENGTH = 3000; export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days +export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; + //#region hard limits // If you change DB_* values, you must also change the DB schema. diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index 7be533588564..fe2c63e7d646 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -14,10 +14,10 @@ import type { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient, MiAbuseUserReport, + MiMeta, MiUser, } from '@/models/_.js'; import { EmailService } from '@/core/EmailService.js'; -import { MetaService } from '@/core/MetaService.js'; import { RoleService } from '@/core/RoleService.js'; import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -27,15 +27,19 @@ import { IdService } from './IdService.js'; @Injectable() export class AbuseReportNotificationService implements OnApplicationShutdown { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.abuseReportNotificationRecipientRepository) private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, + private idService: IdService, private roleService: RoleService, private systemWebhookService: SystemWebhookService, private emailService: EmailService, - private metaService: MetaService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { @@ -93,10 +97,8 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .filter(x => x != null), ); - // 送信先の鮮度を保つため、毎回取得する - const meta = await this.metaService.fetch(true); recipientEMailAddresses.push( - ...(meta.email ? [meta.email] : []), + ...(this.meta.email ? [this.meta.email] : []), ); if (recipientEMailAddresses.length <= 0) { diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index b6b591d24036..6e3125044c68 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -22,13 +22,15 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { MetaService } from '@/core/MetaService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; @Injectable() export class AccountMoveService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -57,7 +59,6 @@ export class AccountMoveService { private perUserFollowingChart: PerUserFollowingChart, private federatedInstanceService: FederatedInstanceService, private instanceChart: InstanceChart, - private metaService: MetaService, private relayService: RelayService, private queueService: QueueService, ) { @@ -276,7 +277,7 @@ export class AccountMoveService { if (this.userEntityService.isRemoteUser(oldAccount)) { this.federatedInstanceService.fetch(oldAccount.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowers(i.host, false); } }); diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index ad852fdd6e6e..1da76e6a237f 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -7,7 +7,8 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Injectable } from '@nestjs/common'; -import * as nsfw from 'nsfwjs'; +import { default as nsfw } from 'nsfwjs'; +import { Tensor3D } from '@tensorflow/tfjs-core'; import si from 'systeminformation'; import { Mutex } from 'async-mutex'; import { bindThis } from '@/decorators.js'; @@ -51,7 +52,7 @@ export class AiService { } const buffer = await fs.promises.readFile(path); - const image = await tf.node.decodeImage(buffer, 3) as any; + const image = await tf.node.decodeImage(buffer as any, 3) as Tensor3D; try { const predictions = await this.model.classify(image); return predictions; diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 793d8974b34b..e827ffa68c0f 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -123,11 +123,14 @@ export class AntennaService implements OnApplicationShutdown { if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { - const listUsers = (await this.userListMembershipsRepository.findBy({ - userListId: antenna.userListId!, - })).map(x => x.userId); - - if (!listUsers.includes(note.userId)) return false; + if (antenna.userListId == null) return false; + const exists = await this.userListMembershipsRepository.exists({ + where: { + userListId: antenna.userListId, + userId: note.userId, + }, + }); + if (!exists) return false; } else if (antenna.src === 'users') { const accts = antenna.users.map(x => { const { username, host } = Acct.parse(x); diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 8b54bbe01241..33861ae14fb6 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -5,7 +5,11 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; +import { IsNull } from 'typeorm'; +import type { + AvatarDecorationsRepository, InstancesRepository, MiAvatarDecoration, + MiUser, UsersRepository, +} from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -13,23 +17,38 @@ import { bindThis } from '@/decorators.js'; import { MemorySingleCache } from '@/misc/cache.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; +import type { Config } from '@/config.js'; +import { HttpRequestService } from './HttpRequestService.js'; @Injectable() export class AvatarDecorationService implements OnApplicationShutdown { public cache: MemorySingleCache; + public cacheWithRemote: MemorySingleCache; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.avatarDecorationsRepository) private avatarDecorationsRepository: AvatarDecorationsRepository, + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private idService: IdService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, + private httpRequestService: HttpRequestService, ) { - this.cache = new MemorySingleCache(1000 * 60 * 30); + this.cache = new MemorySingleCache(1000 * 60 * 30); // 30s + this.cacheWithRemote = new MemorySingleCache(1000 * 60 * 30); // 30s this.redisForSub.on('message', this.onMessage); } @@ -94,6 +113,95 @@ export class AvatarDecorationService implements OnApplicationShutdown { } } + @bindThis + private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { + return appendQuery( + `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, + query({ + url, + ...(mode ? { [mode]: '1' } : {}), + }), + ); + } + + @bindThis + public async remoteUserUpdate(user: MiUser) { + const userHost = user.host ?? ''; + const instance = await this.instancesRepository.findOneBy({ host: userHost }); + const userHostUrl = `https://${user.host}`; + const showUserApiUrl = `${userHostUrl}/api/users/show`; + + if (instance?.softwareName !== 'misskey' && instance?.softwareName !== 'cherrypick') { + return; + } + + const res = await this.httpRequestService.send(showUserApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'username': user.username }), + }); + + const userData = await res.json() as Partial; + const userAvatarDecorations = userData.avatarDecorations ?? undefined; + + if (!userAvatarDecorations || userAvatarDecorations.length === 0) { + const updates = {} as Partial; + updates.avatarDecorations = []; + await this.usersRepository.update({ id: user.id }, updates); + return; + } + + const instanceHost = instance.host; + const decorationApiUrl = `https://${instanceHost}/api/get-avatar-decorations`; + const allRes = await this.httpRequestService.send(decorationApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const allDecorations = await allRes.json() as MiAvatarDecoration[]; + + const updates = {} as Partial; + updates.avatarDecorations = []; + for (const avatarDecoration of userAvatarDecorations) { + let name: string; + let description: string; + const avatarDecorationId = avatarDecoration.id; + for (const decoration of allDecorations) { + if (decoration.id === avatarDecorationId) { + name = decoration.name; + description = decoration.description; + break; + } + } + const existingDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId, + }); + const decorationData = { + name: name!, + description: description!, + url: this.getProxiedUrl(avatarDecoration.url as string, 'static'), + remoteId: avatarDecorationId, + host: userHost, + }; + if (existingDecoration == null) { + await this.create(decorationData); + } else { + await this.update(existingDecoration.id, decorationData); + } + const findDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId, + }); + updates.avatarDecorations.push({ + id: findDecoration?.id ?? '', + angle: avatarDecoration.angle ?? 0, + flipH: avatarDecoration.flipH ?? false, + }); + } + await this.usersRepository.update({ id: user.id }, updates); + } + @bindThis public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise { const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); @@ -110,11 +218,16 @@ export class AvatarDecorationService implements OnApplicationShutdown { } @bindThis - public async getAll(noCache = false): Promise { + public async getAll(noCache = false, withRemote = false): Promise { if (noCache) { this.cache.delete(); + this.cacheWithRemote.delete(); + } + if (!withRemote) { + return this.cache.fetch(() => this.avatarDecorationsRepository.find({ where: { host: IsNull() } })); + } else { + return this.cache.fetch(() => this.avatarDecorationsRepository.find()); } - return this.cache.fetch(() => this.avatarDecorationsRepository.find()); } @bindThis diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index d008e7ec52a3..6725ebe75b14 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown { ) { //this.onMessage = this.onMessage.bind(this); - this.userByIdCache = new MemoryKVCache(Infinity); - this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); - this.localUserByIdCache = new MemoryKVCache(Infinity); - this.uriPersonCache = new MemoryKVCache(Infinity); + this.userByIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.localUserByNativeTokenCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.localUserByIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.uriPersonCache = new MemoryKVCache(1000 * 60 * 5); // 5m this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', { lifetime: 1000 * 60 * 30, // 30m @@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown { if (user == null) { this.userByIdCache.delete(body.id); this.localUserByIdCache.delete(body.id); - for (const [k, v] of this.uriPersonCache.cache.entries()) { + for (const [k, v] of this.uriPersonCache.entries) { if (v.value?.id === body.id) { this.uriPersonCache.delete(k); } } } else { this.userByIdCache.set(user.id, user); - for (const [k, v] of this.uriPersonCache.cache.entries()) { + for (const [k, v] of this.uriPersonCache.entries) { if (v.value?.id === user.id) { this.uriPersonCache.set(k, user); } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index c9427bbeb7bd..2f246bbd3854 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -13,6 +13,7 @@ import { import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserSearchService } from '@/core/UserSearchService.js'; +import { WebhookTestService } from '@/core/WebhookTestService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -41,6 +42,7 @@ import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; +import { NoteUpdateService } from './NoteUpdateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; @@ -49,6 +51,7 @@ import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; import { QueryService } from './QueryService.js'; import { ReactionService } from './ReactionService.js'; +import { ReactionsBufferingService } from './ReactionsBufferingService.js'; import { RelayService } from './RelayService.js'; import { RoleService } from './RoleService.js'; import { S3Service } from './S3Service.js'; @@ -183,6 +186,7 @@ const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaServic const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; +const $NoteUpdateService: Provider = { provide: 'NoteUpdateService', useExisting: NoteUpdateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; @@ -192,6 +196,7 @@ const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExis const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; +const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingService', useExisting: ReactionsBufferingService }; const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; @@ -211,6 +216,7 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; +const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; @@ -331,6 +337,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -340,6 +347,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting PushNotificationService, QueryService, ReactionService, + ReactionsBufferingService, RelayService, RoleService, S3Service, @@ -359,6 +367,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting VideoProcessingService, UserWebhookService, SystemWebhookService, + WebhookTestService, UtilityService, FileInfoService, SearchService, @@ -475,6 +484,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -484,6 +494,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $PushNotificationService, $QueryService, $ReactionService, + $ReactionsBufferingService, $RelayService, $RoleService, $S3Service, @@ -503,6 +514,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $VideoProcessingService, $UserWebhookService, $SystemWebhookService, + $WebhookTestService, $UtilityService, $FileInfoService, $SearchService, @@ -620,6 +632,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MfmService, ModerationLogService, NoteCreateService, + NoteUpdateService, NoteDeleteService, NotePiningService, NoteReadService, @@ -629,6 +642,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting PushNotificationService, QueryService, ReactionService, + ReactionsBufferingService, RelayService, RoleService, S3Service, @@ -648,6 +662,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting VideoProcessingService, UserWebhookService, SystemWebhookService, + WebhookTestService, UtilityService, FileInfoService, SearchService, @@ -763,6 +778,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MfmService, $ModerationLogService, $NoteCreateService, + $NoteUpdateService, $NoteDeleteService, $NotePiningService, $NoteReadService, @@ -772,6 +788,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $PushNotificationService, $QueryService, $ReactionService, + $ReactionsBufferingService, $RelayService, $RoleService, $S3Service, @@ -791,6 +808,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $VideoProcessingService, $UserWebhookService, $SystemWebhookService, + $WebhookTestService, $UtilityService, $FileInfoService, $SearchService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 7e11b9cdca15..5db3c5b98035 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @Injectable() export class CustomEmojiService implements OnApplicationShutdown { - private cache: MemoryKVCache; + private emojisCache: MemoryKVCache; public localEmojisCache: RedisSingleCache>; constructor( @@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown { private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { - this.cache = new MemoryKVCache(1000 * 60 * 60 * 12); + this.emojisCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h this.localEmojisCache = new RedisSingleCache>(this.redisClient, 'localEmojis', { lifetime: 1000 * 60 * 30, // 30m @@ -334,7 +334,7 @@ export class CustomEmojiService implements OnApplicationShutdown { host, })) ?? null; - const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); + const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull); if (emoji == null) return null; return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) @@ -361,7 +361,7 @@ export class CustomEmojiService implements OnApplicationShutdown { */ @bindThis public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise { - const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); + const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null); const emojisQuery: any[] = []; const hosts = new Set(notCachedEmojis.map(e => e.host)); for (const host of hosts) { @@ -376,7 +376,7 @@ export class CustomEmojiService implements OnApplicationShutdown { select: ['name', 'host', 'originalUrl', 'publicUrl'], }) : []; for (const emoji of _emojis) { - this.cache.set(`${emoji.name} ${emoji.host}`, emoji); + this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji); } } @@ -401,7 +401,7 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public dispose(): void { - this.cache.dispose(); + this.emojisCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 79b614edbaca..7f1b8f3efb0f 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -4,12 +4,15 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import { Not, IsNull } from 'typeorm'; +import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js'; import { QueueService } from '@/core/QueueService.js'; -import { UserSuspendService } from '@/core/UserSuspendService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; @Injectable() export class DeleteAccountService { @@ -17,9 +20,14 @@ export class DeleteAccountService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private userSuspendService: UserSuspendService, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, private queueService: QueueService, private globalEventService: GlobalEventService, + private moderationLogService: ModerationLogService, ) { } @@ -27,16 +35,52 @@ export class DeleteAccountService { public async deleteAccount(user: { id: string; host: string | null; - }): Promise { + }, moderator?: MiUser): Promise { const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); if (_user.isRoot) throw new Error('cannot delete a root account'); + if (moderator != null) { + this.moderationLogService.log(moderator, 'deleteAccount', { + userId: user.id, + userUsername: _user.username, + userHost: user.host, + }); + } + // 物理削除する前にDelete activityを送信する - await this.userSuspendService.doPostSuspend(user).catch(e => {}); + if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - this.queueService.createDeleteAccountJob(user, { - soft: false, - }); + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user, content, inbox, true); + } + + this.queueService.createDeleteAccountJob(user, { + soft: false, + }); + } else { + // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + this.queueService.createDeleteAccountJob(user, { + soft: true, + }); + } await this.usersRepository.update(user.id, { isDeleted: true, diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 21ae798f9fca..93f4a382462c 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -42,7 +42,7 @@ export class DownloadService { const timeout = 30 * 1000; const operationTimeout = 60 * 1000; - const maxSize = this.config.maxFileSize ?? 262144000; + const maxSize = this.config.maxFileSize; const urlObj = new URL(url); let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 8aa04b4da733..c332e5a0a8f3 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -11,11 +11,10 @@ import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import { IsNull } from 'typeorm'; import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; import Logger from '@/logger.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @@ -99,6 +98,9 @@ export class DriveService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -115,7 +117,6 @@ export class DriveService { private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, - private metaService: MetaService, private downloadService: DownloadService, private internalStorageService: InternalStorageService, private s3Service: S3Service, @@ -149,9 +150,7 @@ export class DriveService { // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); - const meta = await this.metaService.fetch(); - - if (meta.useObjectStorage) { + if (this.meta.useObjectStorage) { //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); @@ -170,11 +169,11 @@ export class DriveService { ext = ''; } - const baseUrl = meta.objectStorageBaseUrl - ?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; + const baseUrl = this.meta.objectStorageBaseUrl + ?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`; // for original - const key = `${meta.objectStoragePrefix}/${randomUUID()}${ext}`; + const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`; const url = `${ baseUrl }/${ key }`; // for alts @@ -191,7 +190,7 @@ export class DriveService { ]; if (alts.webpublic) { - webpublicKey = `${meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; + webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); @@ -199,7 +198,7 @@ export class DriveService { } if (alts.thumbnail) { - thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; + thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); @@ -376,10 +375,8 @@ export class DriveService { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; - const meta = await this.metaService.fetch(); - const params = { - Bucket: meta.objectStorageBucket, + Bucket: this.meta.objectStorageBucket, Key: key, Body: stream, ContentType: type, @@ -392,9 +389,9 @@ export class DriveService { // 許可されているファイル形式でしか拡張子をつけない ext ? correctFilename(filename, ext) : filename, ); - if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; + if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - await this.s3Service.upload(meta, params) + await this.s3Service.upload(this.meta, params) .then( result => { if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput @@ -460,32 +457,31 @@ export class DriveService { ext = null, }: AddFileArgs): Promise { let skipNsfwCheck = false; - const instance = await this.metaService.fetch(); const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw; if (user == null) { skipNsfwCheck = true; } else if (userRoleNSFW) { skipNsfwCheck = true; } - if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; - if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; - if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; + if (this.meta.sensitiveMediaDetection === 'none') skipNsfwCheck = true; + if (user && this.meta.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; + if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; const info = await this.fileInfoService.getFileInfo(path, { skipSensitiveDetection: skipNsfwCheck, sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる - instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : - instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : - instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : - instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : + this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : + this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : + this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : + this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : 0.5, sensitiveThresholdForPorn: 0.75, - enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos, }); this.registerLogger.info(`${JSON.stringify(info)}`); // 現状 false positive が多すぎて実用に耐えない - //if (info.porn && instance.disallowUploadWhenPredictedAsPorn) { + //if (info.porn && this.meta.disallowUploadWhenPredictedAsPorn) { // throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.'); //} @@ -589,9 +585,9 @@ export class DriveService { sensitive ?? false : false; - if (user && this.utilityService.isMediaSilencedHost(instance.mediaSilencedHosts, user.host)) file.isSensitive = true; + if (user && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) file.isSensitive = true; if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; - if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + if (info.sensitive && this.meta.setSensitiveFlagAutomatically) file.isSensitive = true; if (userRoleNSFW) file.isSensitive = true; if (url !== null) { @@ -652,7 +648,7 @@ export class DriveService { // ローカルユーザーのみ this.perUserDriveChart.update(file, true); } else { - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateDrive(file, true); } } @@ -798,7 +794,7 @@ export class DriveService { // ローカルユーザーのみ this.perUserDriveChart.update(file, false); } else { - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateDrive(file, false); } } @@ -820,14 +816,13 @@ export class DriveService { @bindThis public async deleteObjectStorageFile(key: string) { - const meta = await this.metaService.fetch(); try { const param = { - Bucket: meta.objectStorageBucket, + Bucket: this.meta.objectStorageBucket, Key: key, } as DeleteObjectCommandInput; - await this.s3Service.delete(meta, param); + await this.s3Service.delete(this.meta, param); } catch (err: any) { if (err.name === 'NoSuchKey') { this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 435dbbae28a9..a176474b953f 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -5,18 +5,17 @@ import { URLSearchParams } from 'node:url'; import * as nodemailer from 'nodemailer'; +import juice from 'juice'; import { Inject, Injectable } from '@nestjs/common'; import { validate as validateEmail } from 'deep-email-validator'; -import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import type { UserProfilesRepository } from '@/models/_.js'; +import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { QueueService } from '@/core/QueueService.js'; @Injectable() export class EmailService { @@ -26,49 +25,41 @@ export class EmailService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private metaService: MetaService, private loggerService: LoggerService, private utilityService: UtilityService, private httpRequestService: HttpRequestService, - private queueService: QueueService, ) { this.logger = this.loggerService.getLogger('email'); } @bindThis public async sendEmail(to: string, subject: string, html: string, text: string) { - const meta = await this.metaService.fetch(true); - - if (!meta.enableEmail) return; + if (!this.meta.enableEmail) return; const iconUrl = `${this.config.url}/static-assets/mi-white.png`; const emailSettingUrl = `${this.config.url}/settings/email`; - const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + const enableAuth = this.meta.smtpUser != null && this.meta.smtpUser !== ''; const transporter = nodemailer.createTransport({ - host: meta.smtpHost, - port: meta.smtpPort, - secure: meta.smtpSecure, + host: this.meta.smtpHost, + port: this.meta.smtpPort, + secure: this.meta.smtpSecure, ignoreTLS: !enableAuth, proxy: this.config.proxySmtp, auth: enableAuth ? { - user: meta.smtpUser, - pass: meta.smtpPass, + user: this.meta.smtpUser, + pass: this.meta.smtpPass, } : undefined, } as any); - try { - // TODO: htmlサニタイズ - const info = await transporter.sendMail({ - from: meta.email!, - to: to, - subject: subject, - text: text, - html: ` + const htmlContent = ` @@ -133,7 +124,7 @@ export class EmailService {
- +

${ subject }

@@ -147,7 +138,18 @@ export class EmailService { ${ this.config.host } -`, +`; + + const inlinedHtml = juice(htmlContent); + + try { + // TODO: htmlサニタイズ + const info = await transporter.sendMail({ + from: this.meta.email!, + to: to, + subject: subject, + text: text, + html: inlinedHtml, }); this.logger.info(`Message sent: ${info.messageId}`); @@ -162,8 +164,6 @@ export class EmailService { available: boolean; reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist'; }> { - const meta = await this.metaService.fetch(); - const exist = await this.userProfilesRepository.countBy({ emailVerified: true, email: emailAddress, @@ -181,11 +181,11 @@ export class EmailService { reason?: string | null, } = { valid: true, reason: null }; - if (meta.enableActiveEmailValidation) { - if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) { - validated = await this.verifyMail(emailAddress, meta.verifymailAuthKey); - } else if (meta.enableTruemailApi && meta.truemailInstance && meta.truemailAuthKey != null) { - validated = await this.trueMail(meta.truemailInstance, emailAddress, meta.truemailAuthKey); + if (this.meta.enableActiveEmailValidation) { + if (this.meta.enableVerifymailApi && this.meta.verifymailAuthKey != null) { + validated = await this.verifyMail(emailAddress, this.meta.verifymailAuthKey); + } else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) { + validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey); } else { validated = await validateEmail({ email: emailAddress, @@ -215,7 +215,7 @@ export class EmailService { } const emailDomain: string = emailAddress.split('@')[1]; - const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain); + const isBanned = this.utilityService.isBlockedHost(this.meta.bannedEmailDomains, emailDomain); if (isBanned) { return { diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 169285f0333d..6bd6cb8d9bce 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -15,7 +15,7 @@ import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; import { type predictionType } from 'nsfwjs'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; -import { encode } from 'blurhash'; +import * as blurhash from 'blurhash'; import { createTempDir } from '@/misc/create-temp.js'; import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; @@ -452,7 +452,7 @@ export class FileInfoService { } /** - * Calculate average color of image + * Calculate blurhash string of image */ @bindThis private getBlurhash(path: string, type: string): Promise { @@ -467,7 +467,7 @@ export class FileInfoService { let hash; try { - hash = encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); + hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); } catch (e) { return reject(e); } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 87aa70713e61..afb148025a02 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -119,7 +119,10 @@ export interface NoteEventTypes { }; updated: { cw: string | null; - text: string; + text: string | null; + files: Packed<'DriveFile'>[]; + fileIds: string[]; + poll: any | null; }; reacted: { reaction: string; @@ -241,7 +244,7 @@ export interface InternalEventTypes { avatarDecorationCreated: MiAvatarDecoration; avatarDecorationDeleted: MiAvatarDecoration; avatarDecorationUpdated: MiAvatarDecoration; - metaUpdated: MiMeta; + metaUpdated: { before?: MiMeta; after: MiMeta; }; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; updateUserProfile: MiUserProfile; diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index eb192ee6dafc..793bbeecb162 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -10,16 +10,18 @@ import type { MiUser } from '@/models/User.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { IdService } from '@/core/IdService.js'; import type { MiHashtag } from '@/models/Hashtag.js'; -import type { HashtagsRepository } from '@/models/_.js'; +import type { HashtagsRepository, MiMeta } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { FeaturedService } from '@/core/FeaturedService.js'; -import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; @Injectable() export class HashtagService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.redis) private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする @@ -29,7 +31,6 @@ export class HashtagService { private userEntityService: UserEntityService, private featuredService: FeaturedService, private idService: IdService, - private metaService: MetaService, private utilityService: UtilityService, ) { } @@ -160,10 +161,9 @@ export class HashtagService { @bindThis public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise { - const instance = await this.metaService.fetch(); - const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); + const hiddenTags = this.meta.hiddenTags.map(t => normalizeForSearch(t)); if (hiddenTags.includes(hashtag)) return; - if (this.utilityService.isKeyWordIncluded(hashtag, instance.sensitiveWords)) return; + if (this.utilityService.isKeyWordIncluded(hashtag, this.meta.sensitiveWords)) return; // YYYYMMDDHHmm (10分間隔) const now = new Date(); diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index ec630f804e7c..3d88d0aefe97 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -52,7 +52,7 @@ export class MetaService implements OnApplicationShutdown { switch (type) { case 'metaUpdated': { this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, + ...(body.after), proxyAccount: null, // joinなカラムは通常取ってこないので }; break; @@ -141,7 +141,7 @@ export class MetaService implements OnApplicationShutdown { }); } - this.globalEventService.publishInternalEvent('metaUpdated', updated); + this.globalEventService.publishInternalEvent('metaUpdated', { before, after: updated }); return updated; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 74536c68f515..d33b228c3dda 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -239,7 +239,7 @@ export class MfmService { return null; } - const { window } = new Window(); + const { happyDOM, window } = new Window(); const doc = window.document; @@ -457,6 +457,10 @@ export class MfmService { appendChildren(nodes, body); - return new XMLSerializer().serializeToString(body); + const serialized = new XMLSerializer().serializeToString(body); + + happyDOM.close().catch(err => {}); + + return serialized; } } diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts index 6c155c9a627c..2c02af217d91 100644 --- a/packages/backend/src/core/ModerationLogService.ts +++ b/packages/backend/src/core/ModerationLogService.ts @@ -9,7 +9,8 @@ import type { ModerationLogsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { IdService } from '@/core/IdService.js'; import { bindThis } from '@/decorators.js'; -import { ModerationLogPayloads, moderationLogTypes } from '@/types.js'; +import type { ModerationLogPayloads } from '@/types.js'; +import { moderationLogTypes } from '@/types.js'; @Injectable() export class ModerationLogService { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 32cf3f3e26eb..a348659bc8a3 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -8,13 +8,12 @@ import * as mfm from 'mfm-js'; import { In, DataSource, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import RE2 from 're2'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -23,11 +22,8 @@ import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { IPoll } from '@/models/Poll.js'; import { MiPoll } from '@/models/Poll.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { checkWordMute } from '@/misc/check-word-mute.js'; import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -51,7 +47,6 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; @@ -60,6 +55,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -128,6 +124,7 @@ type MinimumUser = { type Option = { createdAt?: Date | null; + updatedAt?: Date | null; name?: string | null; text?: string | null; reply?: MiNote | null; @@ -151,11 +148,15 @@ type Option = { @Injectable() export class NoteCreateService implements OnApplicationShutdown { #shutdownController = new AbortController(); + private updateNotesCountQueue: CollapsedQueue; constructor( @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.db) private db: DataSource, @@ -210,7 +211,6 @@ export class NoteCreateService implements OnApplicationShutdown { private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, - private metaService: MetaService, private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, @@ -218,7 +218,9 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, - ) { } + ) { + this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); + } @bindThis public async create(user: { @@ -251,10 +253,8 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.channel != null) data.visibleUsers = []; if (data.channel != null) data.localOnly = true; - const meta = await this.metaService.fetch(); - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = meta.sensitiveWords; + const sensitiveWords = this.meta.sensitiveWords; if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { data.visibility = 'home'; } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { @@ -262,17 +262,17 @@ export class NoteCreateService implements OnApplicationShutdown { } } - const hasProhibitedWords = await this.checkProhibitedWordsContain({ + const hasProhibitedWords = this.checkProhibitedWordsContain({ cw: data.cw, text: data.text, pollChoices: data.poll?.choices, - }, meta.prohibitedWords); + }, this.meta.prohibitedWords); if (hasProhibitedWords) { throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); } - const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); + const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host); if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { data.visibility = 'home'; @@ -365,7 +365,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // if the host is media-silenced, custom emojis are not allowed - if (this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, user.host)) emojis = []; + if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); @@ -506,18 +506,16 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - const meta = await this.metaService.fetch(); - this.notesChart.update(note, true); - if (meta.enableChartsForRemoteUser || (user.host == null)) { + if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) { this.perUserNotesChart.update(user, note, true); } // Register host if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.updateNotesCountQueue.enqueue(i.id, 1); + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateNote(i.host, note, true); } }); @@ -851,17 +849,21 @@ export class NoteCreateService implements OnApplicationShutdown { return mentionedUsers; } + @bindThis + public async appendNoteVisibleUser(actor: MiRemoteUser, note: MiNote, cc: string) { + //todo + } + @bindThis private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const meta = await this.metaService.fetch(); - if (!meta.enableFanoutTimeline) return; + if (!this.meta.enableFanoutTimeline) return; const r = this.redisForTimelines.pipeline(); if (note.channelId) { this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); const channelFollowings = await this.channelFollowingsRepository.find({ where: { @@ -871,9 +873,9 @@ export class NoteCreateService implements OnApplicationShutdown { }); for (const channelFollowing of channelFollowings) { - this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } } else { @@ -911,9 +913,9 @@ export class NoteCreateService implements OnApplicationShutdown { if (!following.withReplies) continue; } - this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } @@ -930,25 +932,25 @@ export class NoteCreateService implements OnApplicationShutdown { if (!userListMembership.withReplies) continue; } - this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); + this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r); } } // 自分自身のHTL if (note.userHost == null) { if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } } // 自分自身以外への返信 if (isReply(note)) { - this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.visibility === 'public' && note.userHost == null) { this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); @@ -957,9 +959,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } } else { - this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r); } if (note.visibility === 'public' && note.userHost == null) { @@ -1018,9 +1020,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } - public async checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) { + public checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) { if (prohibitedWords == null) { - prohibitedWords = (await this.metaService.fetch()).prohibitedWords; + prohibitedWords = this.meta.prohibitedWords; } if ( @@ -1036,12 +1038,23 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public dispose(): void { + private collapseNotesCount(oldValue: number, newValue: number) { + return oldValue + newValue; + } + + @bindThis + private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) { + await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy); + } + + @bindThis + public async dispose(): Promise { this.#shutdownController.abort(); + await this.updateNotesCountQueue.performAllNow(); } @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); + public async onApplicationShutdown(signal?: string | undefined): Promise { + await this.dispose(); } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 801ed02e00b3..f9f8ace386c4 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -7,7 +7,7 @@ import { Brackets, In } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; -import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; +import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -19,9 +19,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; @@ -32,6 +30,9 @@ export class NoteDeleteService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -42,13 +43,11 @@ export class NoteDeleteService { private instancesRepository: InstancesRepository, private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, private globalEventService: GlobalEventService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, - private metaService: MetaService, private searchService: SearchService, private moderationLogService: ModerationLogService, private notesChart: NotesChart, @@ -92,7 +91,7 @@ export class NoteDeleteService { this.deliverToConcerned(user, note, content); } - // also deliever delete activity to cascaded notes + // also deliver delete activity to cascaded notes const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes for (const cascadingNote of federatedLocalCascadingNotes) { if (!cascadingNote.user) continue; @@ -102,17 +101,15 @@ export class NoteDeleteService { } //#endregion - const meta = await this.metaService.fetch(); - this.notesChart.update(note, false); - if (meta.enableChartsForRemoteUser || (user.host == null)) { + if (this.meta.enableChartsForRemoteUser || (user.host == null)) { this.perUserNotesChart.update(user, note, false); } if (this.userEntityService.isRemoteUser(user)) { this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateNote(i.host, note, false); } }); diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts new file mode 100644 index 000000000000..1b40804aa83d --- /dev/null +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -0,0 +1,313 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from 'node:timers/promises'; +import util from 'util'; +import { In, DataSource } from 'typeorm'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { RelayService } from '@/core/RelayService.js'; +import { DI } from '@/di-symbols.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { bindThis } from '@/decorators.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { SearchService } from '@/core/SearchService.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { MiDriveFile, MiPollVote } from '@/models/_.js'; +import { MiPoll, IPoll } from '@/models/Poll.js'; +import { concat } from '@/misc/prelude/array.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import Logger from '@/logger.js'; +import { NoteEntityService } from './entities/NoteEntityService.js'; +import { LoggerService } from './LoggerService.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + updatedAt?: Date | null; + files?: MiDriveFile[] | null; + name?: string | null; + text?: string | null; + cw?: string | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + poll?: IPoll | null; +}; + +@Injectable() +export class NoteUpdateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + private logger: Logger; + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private driveFileEntityService: DriveFileEntityService, + private globalEventService: GlobalEventService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('NoteUpdateService'); + } + + @bindThis + public async update(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, data: Option, note: MiNote, silent = false): Promise { + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + + // Parse MFM if needed + if (!tags || !emojis) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + const updatedNote = await this.updateNote(user, note, data, tags, emojis); + + if (updatedNote) { + setImmediate('post updated', { signal: this.#shutdownController.signal }).then( + () => this.postNoteUpdated(updatedNote, user, silent), + () => { /* aborted, ignore this */ }, + ); + } + + return updatedNote; + } + + @bindThis + private async updateNote(user: { + id: MiUser['id']; host: MiUser['host']; + }, note: MiNote, data: Option, tags: string[], emojis: string[]): Promise { + const values = new MiNote({ + updatedAt: data.updatedAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + }); + + // 投稿を更新 + try { + if (note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const old_poll = await transactionalEntityManager.findOneBy(MiPoll, { noteId: note.id }); + if (old_poll?.choices.toString() !== data.poll?.choices.toString() || old_poll?.multiple !== data.poll?.multiple) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + await transactionalEntityManager.delete(MiPollVote, { noteId: note.id }); + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll?.choices, + expiresAt: data.poll?.expiresAt, + multiple: data.poll?.multiple, + votes: new Array(data.poll?.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + await transactionalEntityManager.insert(MiPoll, poll); + } + } + }); + } else if (!note.hasPoll && values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (values.hasPoll) { + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll?.choices, + expiresAt: data.poll?.expiresAt, + multiple: data.poll?.multiple, + votes: new Array(data.poll?.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(MiPoll, poll); + } + }); + } else if (note.hasPoll && !values.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, { id: note.id }, values); + + if (!values.hasPoll) { + await transactionalEntityManager.delete(MiPoll, { noteId: note.id }); + } + }); + } else { + await this.notesRepository.update({ id: note.id }, values); + } + + return await this.notesRepository.findOneBy({ id: note.id }); + } catch (e) { + this.logger.error(`${JSON.stringify(e)}`); + + throw e; + } + } + + @bindThis + private async postNoteUpdated(note: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, silent: boolean) { + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + const noteObj = await this.noteEntityService.pack(note, user); + + this.logger.info(`Note updated: ${note.uri ?? note.id}`); + this.logger.debug(`noteObj: ${JSON.stringify(noteObj)}`); + this.globalEventService.publishNoteStream(note.id, 'updated', { + cw: noteObj.cw ?? null, + text: noteObj.text, + files: noteObj.files ?? [], + fileIds: noteObj.fileIds ?? [], + poll: noteObj.poll ?? null, + }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + await (async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const noteActivity = await this.renderNoteActivity(note, user); + + await this.deliverToConcerned(user, note, noteActivity); + })(); + } + //#endregion + } + + // Register to search database + this.reIndex(note); + } + + @bindThis + private async renderNoteActivity(note: MiNote, user: MiUser) { + const content = this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user); + + return this.apRendererService.addContext(content); + } + + @bindThis + private async getMentionedRemoteUsers(note: MiNote) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as MiRemoteUser[]; + } + + @bindThis + private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { + await this.apDeliverManagerService.deliverToFollowers(user, content); + await this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + await this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } + + @bindThis + private reIndex(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.unindexNote(note); + this.searchService.indexNote(note); + } + + @bindThis + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts index 71d663bf9060..c3ff2a68d335 100644 --- a/packages/backend/src/core/ProxyAccountService.ts +++ b/packages/backend/src/core/ProxyAccountService.ts @@ -4,26 +4,25 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import type { MiLocalUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; @Injectable() export class ProxyAccountService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - - private metaService: MetaService, ) { } @bindThis public async fetch(): Promise { - const meta = await this.metaService.fetch(); - if (meta.proxyAccountId == null) return null; - return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as MiLocalUser; + if (this.meta.proxyAccountId == null) return null; + return await this.usersRepository.findOneByOrFail({ id: this.meta.proxyAccountId }) as MiLocalUser; } } diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 6a845b951de6..1479bb00d9b8 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -10,8 +10,7 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; -import type { MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; -import { MetaService } from '@/core/MetaService.js'; +import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RedisKVCache } from '@/misc/cache.js'; @@ -54,13 +53,14 @@ export class PushNotificationService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, - - private metaService: MetaService, ) { this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', { lifetime: 1000 * 60 * 60 * 1, // 1h @@ -73,14 +73,12 @@ export class PushNotificationService implements OnApplicationShutdown { @bindThis public async pushNotification(userId: string, type: T, body: PushNotificationsTypes[T]) { - const meta = await this.metaService.fetch(); - - if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; + if (!this.meta.enableServiceWorker || this.meta.swPublicKey == null || this.meta.swPrivateKey == null) return; // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 push.setVapidDetails(this.config.url, - meta.swPublicKey, - meta.swPrivateKey); + this.meta.swPublicKey, + this.meta.swPrivateKey); const subscriptions = await this.subscriptionsCache.fetch(userId); diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 80827a500b56..f35e456556df 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -87,6 +87,12 @@ export class QueueService { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('bakeBufferedReactions', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); } @bindThis @@ -452,10 +458,15 @@ export class QueueService { /** * @see UserWebhookDeliverJobData - * @see WebhookDeliverProcessorService + * @see UserWebhookDeliverProcessorService */ @bindThis - public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { + public userWebhookDeliver( + webhook: MiWebhook, + type: typeof webhookEventTypes[number], + content: unknown, + opts?: { attempts?: number }, + ) { const data: UserWebhookDeliverJobData = { type, content, @@ -468,7 +479,7 @@ export class QueueService { }; return this.userWebhookDeliverQueue.add(webhook.id, data, { - attempts: 4, + attempts: opts?.attempts ?? 4, backoff: { type: 'custom', }, @@ -479,10 +490,15 @@ export class QueueService { /** * @see SystemWebhookDeliverJobData - * @see WebhookDeliverProcessorService + * @see SystemWebhookDeliverProcessorService */ @bindThis - public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) { + public systemWebhookDeliver( + webhook: MiSystemWebhook, + type: SystemWebhookEventType, + content: unknown, + opts?: { attempts?: number }, + ) { const data: SystemWebhookDeliverJobData = { type, content, @@ -494,7 +510,7 @@ export class QueueService { }; return this.systemWebhookDeliverQueue.add(webhook.id, data, { - attempts: 4, + attempts: opts?.attempts ?? 4, backoff: { type: 'custom', }, diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 371207c33a7d..6f9fe53937bc 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -4,9 +4,8 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -21,7 +20,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; @@ -30,9 +28,10 @@ import { RoleService } from '@/core/RoleService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; const FALLBACK = '\u2764'; -const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; const legacies: Record = { 'like': '👍', @@ -71,8 +70,8 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; @Injectable() export class ReactionService { constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.meta) + private meta: MiMeta, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -87,12 +86,12 @@ export class ReactionService { private emojisRepository: EmojisRepository, private utilityService: UtilityService, - private metaService: MetaService, private customEmojiService: CustomEmojiService, private roleService: RoleService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, + private reactionsBufferingService: ReactionsBufferingService, private idService: IdService, private featuredService: FeaturedService, private globalEventService: GlobalEventService, @@ -105,8 +104,6 @@ export class ReactionService { @bindThis public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) { - const meta = await this.metaService.fetch(); - // Check blocking if (note.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); @@ -152,7 +149,7 @@ export class ReactionService { } // for media silenced host, custom emoji reactions are not allowed - if (reacterHost != null && this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, reacterHost)) { + if (reacterHost != null && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, reacterHost)) { reaction = FALLBACK; } } else { @@ -174,7 +171,6 @@ export class ReactionService { reaction, }; - // Create reaction try { await this.noteReactionsRepository.insert(record); } catch (e) { @@ -198,16 +194,20 @@ export class ReactionService { } // Increment reactions count - const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; - await this.notesRepository.createQueryBuilder().update() - .set({ - reactions: () => sql, - ...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? { - reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`, - } : {}), - }) - .where('id = :id', { id: note.id }) - .execute(); + if (this.meta.enableReactionsBuffering) { + await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache); + } else { + const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + ...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? { + reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`, + } : {}), + }) + .where('id = :id', { id: note.id }) + .execute(); + } // 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新 if ( @@ -227,7 +227,7 @@ export class ReactionService { } } - if (meta.enableChartsForRemoteUser || (user.host == null)) { + if (this.meta.enableChartsForRemoteUser || (user.host == null)) { this.perUserReactionsChart.update(user, note); } @@ -305,14 +305,18 @@ export class ReactionService { } // Decrement reactions count - const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; - await this.notesRepository.createQueryBuilder().update() - .set({ - reactions: () => sql, - reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`, - }) - .where('id = :id', { id: note.id }) - .execute(); + if (this.meta.enableReactionsBuffering) { + await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction); + } else { + const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`, + }) + .where('id = :id', { id: note.id }) + .execute(); + } this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, @@ -334,8 +338,21 @@ export class ReactionService { } /** - * 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、 - * データベース上には存在する「0個のリアクションがついている」という情報を削除する。 + * - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する + * - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果) + */ + @bindThis + public convertLegacyReaction(reaction: string): string { + reaction = this.decodeReaction(reaction).reaction; + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + return reaction; + } + + // TODO: 廃止 + /** + * - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する + * - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果) + * - データベース上には存在する「0個のリアクションがついている」という情報を削除する */ @bindThis public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] { @@ -348,10 +365,7 @@ export class ReactionService { return count > 0; }) .map(([reaction, count]) => { - // unchecked indexed access - const convertedReaction = legacies[reaction] as string | undefined; - - const key = this.decodeReaction(convertedReaction ?? reaction).reaction; + const key = this.convertLegacyReaction(reaction); return [key, count] as const; }) @@ -406,11 +420,4 @@ export class ReactionService { host: undefined, }; } - - @bindThis - public convertLegacyReaction(reaction: string): string { - reaction = this.decodeReaction(reaction).reaction; - if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; - return reaction; - } } diff --git a/packages/backend/src/core/ReactionsBufferingService.ts b/packages/backend/src/core/ReactionsBufferingService.ts new file mode 100644 index 000000000000..b4207c51062c --- /dev/null +++ b/packages/backend/src/core/ReactionsBufferingService.ts @@ -0,0 +1,211 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { MiNote } from '@/models/Note.js'; +import { bindThis } from '@/decorators.js'; +import type { MiUser, NotesRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas'; +const REDIS_PAIR_PREFIX = 'reactionsBufferPairs'; + +@Injectable() +export class ReactionsBufferingService implements OnApplicationShutdown { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.redisForReactions) + private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + ) { + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'metaUpdated': { + // リアクションバッファリングが有効→無効になったら即bake + if (body.before != null && body.before.enableReactionsBuffering && !body.after.enableReactionsBuffering) { + this.bake(); + } + break; + } + default: + break; + } + } + } + + @bindThis + public async create(noteId: MiNote['id'], userId: MiUser['id'], reaction: string, currentPairs: string[]): Promise { + const pipeline = this.redisForReactions.pipeline(); + pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, 1); + for (let i = 0; i < currentPairs.length; i++) { + pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, i, currentPairs[i]); + } + pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, Date.now(), `${userId}/${reaction}`); + pipeline.zremrangebyrank(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -(PER_NOTE_REACTION_USER_PAIR_CACHE_MAX + 1)); + await pipeline.exec(); + } + + @bindThis + public async delete(noteId: MiNote['id'], userId: MiUser['id'], reaction: string): Promise { + const pipeline = this.redisForReactions.pipeline(); + pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, -1); + pipeline.zrem(`${REDIS_PAIR_PREFIX}:${noteId}`, `${userId}/${reaction}`); + // TODO: 「消した要素一覧」も持っておかないとcreateされた時に上書きされて復活する + await pipeline.exec(); + } + + @bindThis + public async get(noteId: MiNote['id']): Promise<{ + deltas: Record; + pairs: ([MiUser['id'], string])[]; + }> { + const pipeline = this.redisForReactions.pipeline(); + pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`); + pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1); + const results = await pipeline.exec(); + + const resultDeltas = results![0][1] as Record; + const resultPairs = results![1][1] as string[]; + + const deltas = {} as Record; + for (const [name, count] of Object.entries(resultDeltas)) { + deltas[name] = parseInt(count); + } + + const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]); + + return { + deltas, + pairs, + }; + } + + @bindThis + public async getMany(noteIds: MiNote['id'][]): Promise; + pairs: ([MiUser['id'], string])[]; + }>> { + const map = new Map; + pairs: ([MiUser['id'], string])[]; + }>(); + + const pipeline = this.redisForReactions.pipeline(); + for (const noteId of noteIds) { + pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`); + pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1); + } + const results = await pipeline.exec(); + + const opsForEachNotes = 2; + for (let i = 0; i < noteIds.length; i++) { + const noteId = noteIds[i]; + const resultDeltas = results![i * opsForEachNotes][1] as Record; + const resultPairs = results![i * opsForEachNotes + 1][1] as string[]; + + const deltas = {} as Record; + for (const [name, count] of Object.entries(resultDeltas)) { + deltas[name] = parseInt(count); + } + + const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]); + + map.set(noteId, { + deltas, + pairs, + }); + } + + return map; + } + + // TODO: scanは重い可能性があるので、別途 bufferedNoteIds を直接Redis上に持っておいてもいいかもしれない + @bindThis + public async bake(): Promise { + const bufferedNoteIds = []; + let cursor = '0'; + do { + // https://github.com/redis/ioredis#transparent-key-prefixing + const result = await this.redisForReactions.scan( + cursor, + 'MATCH', + `${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:*`, + 'COUNT', + '1000'); + + cursor = result[0]; + bufferedNoteIds.push(...result[1].map(x => x.replace(`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:`, ''))); + } while (cursor !== '0'); + + const bufferedMap = await this.getMany(bufferedNoteIds); + + // clear + const pipeline = this.redisForReactions.pipeline(); + for (const noteId of bufferedNoteIds) { + pipeline.del(`${REDIS_DELTA_PREFIX}:${noteId}`); + pipeline.del(`${REDIS_PAIR_PREFIX}:${noteId}`); + } + await pipeline.exec(); + + // TODO: SQL一個にまとめたい + for (const [noteId, buffered] of bufferedMap) { + const sql = Object.entries(buffered.deltas) + .map(([reaction, count]) => + `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + ${count})::text::jsonb)`) + .join(' || '); + + this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + reactionAndUserPairCache: buffered.pairs.map(x => x.join('/')), + }) + .where('id = :id', { id: noteId }) + .execute(); + } + } + + @bindThis + public mergeReactions(src: MiNote['reactions'], delta: Record): MiNote['reactions'] { + const reactions = { ...src }; + for (const [name, count] of Object.entries(delta)) { + if (reactions[name] != null) { + reactions[name] += count; + } else { + reactions[name] = count; + } + } + return reactions; + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 8dd3d64f5b29..db32114346ef 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -35,7 +35,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new MemorySingleCache(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache(1000 * 60 * 10); // 10m } @bindThis diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 7f939b99c7a6..51dca3da5947 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { ModuleRef } from '@nestjs/core'; +import { reversiUpdateKeys } from 'misskey-js'; import * as Reversi from 'misskey-reversi'; import { IsNull, LessThan, MoreThan } from 'typeorm'; import type { @@ -399,7 +400,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) { + public isValidReversiUpdateKey(key: unknown): key is typeof reversiUpdateKeys[number] { + if (typeof key !== 'string') return false; + return (reversiUpdateKeys as string[]).includes(key); + } + + @bindThis + public isValidReversiUpdateValue(key: K, value: unknown): value is MiReversiGame[K] { + switch (key) { + case 'map': + return Array.isArray(value) && value.every(row => typeof row === 'string'); + case 'bw': + return typeof value === 'string' && ['random', '1', '2'].includes(value); + case 'isLlotheo': + return typeof value === 'boolean'; + case 'canPutEverywhere': + return typeof value === 'boolean'; + case 'loopedBoard': + return typeof value === 'boolean'; + case 'timeLimitForEachTurn': + return typeof value === 'number' && value >= 0; + default: + return false; + } + } + + @bindThis + public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: K, value: MiReversiGame[K]) { const game = await this.get(gameId); if (game == null) throw new Error('game not found'); if (game.isStarted) return; @@ -407,10 +434,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { if ((game.user1Id === user.id) && game.user1Ready) return; if ((game.user2Id === user.id) && game.user2Ready) return; - if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return; - - // TODO: より厳格なバリデーション - const updatedGame = { ...game, [key]: value, diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 796677467364..f0ebd569ddce 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -8,6 +8,7 @@ import * as Redis from 'ioredis'; import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import type { + MiMeta, MiRole, MiRoleAssignment, RoleAssignmentsRepository, @@ -18,7 +19,6 @@ import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -35,6 +35,14 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canEditNote: boolean; + canInitiateConversation: boolean; + canCreateContent: boolean; + canUpdateContent: boolean; + canDeleteContent: boolean; + canPurgeAccount: boolean; + canUpdateAvatar: boolean; + canUpdateBanner: boolean; mentionLimit: number; canInvite: boolean; inviteLimit: number; @@ -58,12 +66,25 @@ export type RolePolicies = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + canImportAntennas: boolean; + canImportBlocking: boolean; + canImportFollowing: boolean; + canImportMuting: boolean; + canImportUserLists: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + canEditNote: true, + canInitiateConversation: true, + canCreateContent: true, + canUpdateContent: true, + canDeleteContent: true, + canPurgeAccount: true, + canUpdateAvatar: true, + canUpdateBanner: true, mentionLimit: 20, canInvite: false, inviteLimit: 0, @@ -87,6 +108,11 @@ export const DEFAULT_POLICIES: RolePolicies = { userEachUserListsLimit: 50, rateLimitFactor: 1, avatarDecorationLimit: 1, + canImportAntennas: true, + canImportBlocking: true, + canImportFollowing: true, + canImportMuting: true, + canImportUserLists: true, }; @Injectable() @@ -101,8 +127,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.meta) + private meta: MiMeta, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, @@ -119,7 +145,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @Inject(DI.roleAssignmentsRepository) private roleAssignmentsRepository: RoleAssignmentsRepository, - private metaService: MetaService, private cacheService: CacheService, private userEntityService: UserEntityService, private globalEventService: GlobalEventService, @@ -127,10 +152,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private moderationLogService: ModerationLogService, private fanoutTimelineService: FanoutTimelineService, ) { - //this.onMessage = this.onMessage.bind(this); - - this.rolesCache = new MemorySingleCache(1000 * 60 * 60 * 1); - this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); + this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h + this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m this.redisForSub.on('message', this.onMessage); } @@ -341,8 +364,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async getUserPolicies(userId: MiUser['id'] | null): Promise { - const meta = await this.metaService.fetch(); - const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; + const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies }; if (userId == null) return basePolicies; @@ -366,6 +388,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canEditNote: calc('canEditNote', vs => vs.some(v => v === true)), + canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), + canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), + canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), + canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)), + canPurgeAccount: calc('canPurgeAccount', vs => vs.some(v => v === true)), + canUpdateAvatar: calc('canUpdateAvatar', vs => vs.some(v => v === true)), + canUpdateBanner: calc('canUpdateBanner', vs => vs.some(v => v === true)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), inviteLimit: calc('inviteLimit', vs => Math.max(...vs)), @@ -389,6 +419,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)), rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)), avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)), + canImportAntennas: calc('canImportAntennas', vs => vs.some(v => v === true)), + canImportBlocking: calc('canImportBlocking', vs => vs.some(v => v === true)), + canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), + canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), + canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index bb2a463354a7..6147d0d31fcb 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -10,9 +10,11 @@ import { Injectable } from '@nestjs/common'; import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { NodeHttpHandler, NodeHttpHandlerOptions } from '@smithy/node-http-handler'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; import type { MiMeta } from '@/models/Meta.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { bindThis } from '@/decorators.js'; import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; @Injectable() diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index de4589832815..cc8a3d6461d5 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -8,7 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { DataSource, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; import { MiUser } from '@/models/User.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { IdService } from '@/core/IdService.js'; @@ -20,7 +20,6 @@ import { InstanceActorService } from '@/core/InstanceActorService.js'; import { bindThis } from '@/decorators.js'; import UsersChart from '@/core/chart/charts/users.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { UserService } from '@/core/UserService.js'; @Injectable() @@ -29,6 +28,9 @@ export class SignupService { @Inject(DI.db) private db: DataSource, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -39,7 +41,6 @@ export class SignupService { private userService: UserService, private userEntityService: UserEntityService, private idService: IdService, - private metaService: MetaService, private instanceActorService: InstanceActorService, private usersChart: UsersChart, ) { @@ -88,8 +89,7 @@ export class SignupService { const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent(); if (!opts.ignorePreservedUsernames && !isTheFirstUser) { - const instance = await this.metaService.fetch(true); - const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); if (isPreserved) { throw new Error('USED_USERNAME'); } diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts index bc6851f788da..bb7c6b8c0e02 100644 --- a/packages/backend/src/core/SystemWebhookService.ts +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -54,7 +54,7 @@ export class SystemWebhookService implements OnApplicationShutdown { * SystemWebhook の一覧を取得する. */ @bindThis - public async fetchSystemWebhooks(params?: { + public fetchSystemWebhooks(params?: { ids?: MiSystemWebhook['id'][]; isActive?: MiSystemWebhook['isActive']; on?: MiSystemWebhook['on']; @@ -165,19 +165,24 @@ export class SystemWebhookService implements OnApplicationShutdown { /** * SystemWebhook をWebhook配送キューに追加する * @see QueueService.systemWebhookDeliver + * // TODO: contentの型を厳格化する */ @bindThis - public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) { + public async enqueueSystemWebhook( + webhook: MiSystemWebhook | MiSystemWebhook['id'], + type: T, + content: unknown, + ) { const webhookEntity = typeof webhook === 'string' ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) : webhook; if (!webhookEntity || !webhookEntity.isActive) { - this.logger.info(`Webhook is not active or not found : ${webhook}`); + this.logger.info(`SystemWebhook is not active or not found : ${webhook}`); return; } if (!webhookEntity.on.includes(type)) { - this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`); + this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`); return; } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 6aab8fde70c2..77e7b60beac6 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -13,23 +13,20 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { UserWebhookService } from '@/core/UserWebhookService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import type { ThinUser } from '@/queue/types.js'; import Logger from '../logger.js'; @@ -58,6 +55,9 @@ export class UserFollowingService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -79,13 +79,11 @@ export class UserFollowingService implements OnModuleInit { private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private metaService: MetaService, private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, private webhookService: UserWebhookService, private apRendererService: ApRendererService, private accountMoveService: AccountMoveService, - private fanoutTimelineService: FanoutTimelineService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { @@ -172,7 +170,7 @@ export class UserFollowingService implements OnModuleInit { followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') || - (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost((await this.metaService.fetch()).silencedHosts, follower.host)) + (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost(this.meta.silencedHosts, follower.host)) ) { let autoAccept = false; @@ -277,16 +275,19 @@ export class UserFollowingService implements OnModuleInit { followeeId: followee.id, followerId: follower.id, }); - - // 通知を作成 - if (follower.host === null) { - this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - }, followee.id); - } } if (alreadyFollowed) return; + // 通知を作成 + if (follower.host === null) { + const profile = await this.cacheService.userProfileCache.fetch(followee.id); + + this.notificationService.createNotification(follower.id, 'followRequestAccepted', { + message: profile.followedMessage, + }, followee.id); + } + this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); const [followeeUser, followerUser] = await Promise.all([ @@ -307,14 +308,14 @@ export class UserFollowingService implements OnModuleInit { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { this.federatedInstanceService.fetch(follower.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowing(i.host, true); } }); } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { this.federatedInstanceService.fetch(followee.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowers(i.host, true); } }); @@ -439,14 +440,14 @@ export class UserFollowingService implements OnModuleInit { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { this.federatedInstanceService.fetch(follower.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowing(i.host, false); } }); } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { this.federatedInstanceService.fetch(followee.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowers(i.host, false); } }); diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 51ac99179a6a..92d61cd103f7 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown { ) { this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { lifetime: 1000 * 60 * 60 * 24, // 24h - memoryCacheLifetime: Infinity, + memoryCacheLifetime: 1000 * 60 * 60, // 1h fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => JSON.parse(value), diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d594a223f4e2..7920e58e3678 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Not, IsNull } from 'typeorm'; -import type { FollowingsRepository } from '@/models/_.js'; +import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -13,24 +13,75 @@ import { DI } from '@/di-symbols.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RelationshipJobData } from '@/queue/types.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; @Injectable() export class UserSuspendService { constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + private userEntityService: UserEntityService, private queueService: QueueService, private globalEventService: GlobalEventService, private apRendererService: ApRendererService, + private moderationLogService: ModerationLogService, ) { } @bindThis - public async doPostSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise { + public async suspend(user: MiUser, moderator: MiUser): Promise { + await this.usersRepository.update(user.id, { + isSuspended: true, + }); + + this.moderationLogService.log(moderator, 'suspend', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + (async () => { + await this.postSuspend(user).catch(e => {}); + await this.unFollowAll(user).catch(e => {}); + })(); + } + + @bindThis + public async unsuspend(user: MiUser, moderator: MiUser): Promise { + await this.usersRepository.update(user.id, { + isSuspended: false, + }); + + this.moderationLogService.log(moderator, 'unsuspend', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + (async () => { + await this.postUnsuspend(user).catch(e => {}); + })(); + } + + @bindThis + private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + this.followRequestsRepository.delete({ + followeeId: user.id, + }); + this.followRequestsRepository.delete({ + followerId: user.id, + }); + if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); @@ -58,7 +109,7 @@ export class UserSuspendService { } @bindThis - public async doPostUnsuspend(user: MiUser): Promise { + private async postUnsuspend(user: MiUser): Promise { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); if (this.userEntityService.isLocalUser(user)) { @@ -86,4 +137,26 @@ export class UserSuspendService { } } } + + @bindThis + private async unFollowAll(follower: MiUser) { + const followings = await this.followingsRepository.find({ + where: { + followerId: follower.id, + followeeId: Not(IsNull()), + }, + }); + + const jobs: RelationshipJobData[] = []; + for (const following of followings) { + if (following.followeeId && following.followerId) { + jobs.push({ + from: { id: following.followerId }, + to: { id: following.followeeId }, + silent: true, + }); + } + } + this.queueService.createUnfollowJob(jobs); + } } diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts index e96bfeea9581..8a40a5368804 100644 --- a/packages/backend/src/core/UserWebhookService.ts +++ b/packages/backend/src/core/UserWebhookService.ts @@ -5,8 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { WebhooksRepository } from '@/models/_.js'; -import type { MiWebhook } from '@/models/Webhook.js'; +import { type WebhooksRepository } from '@/models/_.js'; +import { MiWebhook } from '@/models/Webhook.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { GlobalEvents } from '@/core/GlobalEventService.js'; @@ -38,6 +38,31 @@ export class UserWebhookService implements OnApplicationShutdown { return this.activeWebhooks; } + /** + * UserWebhook の一覧を取得する. + */ + @bindThis + public fetchWebhooks(params?: { + ids?: MiWebhook['id'][]; + isActive?: MiWebhook['active']; + on?: MiWebhook['on']; + }): Promise { + const query = this.webhooksRepository.createQueryBuilder('webhook'); + if (params) { + if (params.ids && params.ids.length > 0) { + query.andWhere('webhook.id IN (:...ids)', { ids: params.ids }); + } + if (params.isActive !== undefined) { + query.andWhere('webhook.active = :isActive', { isActive: params.isActive }); + } + if (params.on && params.on.length > 0) { + query.andWhere(':on <@ webhook.on', { on: params.on }); + } + } + + return query.getMany(); + } + @bindThis private async onMessage(_: string, data: string): Promise { const obj = JSON.parse(data); diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 94729250a614..86082ccdcdb1 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -10,12 +10,16 @@ import RE2 from 're2'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { MiMeta } from '@/models/Meta.js'; @Injectable() export class UtilityService { constructor( @Inject(DI.config) private config: Config, + + @Inject(DI.meta) + private meta: MiMeta, ) { } @@ -105,4 +109,19 @@ export class UtilityService { if (host == null) return null; return toASCII(host.toLowerCase()); } + + @bindThis + public isFederationAllowedHost(host: string): boolean { + if (this.meta.federation === 'none') return false; + if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false; + if (this.isBlockedHost(this.meta.blockedHosts, host)) return false; + + return true; + } + + @bindThis + public isFederationAllowedUri(uri: string): boolean { + const host = this.extractDbHost(uri); + return this.isFederationAllowedHost(host); + } } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index ec9f4484a4c0..75ab0a207c45 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -12,10 +12,9 @@ import { } from '@simplewebauthn/server'; import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers'; import { DI } from '@/di-symbols.js'; -import type { UserSecurityKeysRepository } from '@/models/_.js'; +import type { MiMeta, UserSecurityKeysRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiUser } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { @@ -23,7 +22,6 @@ import type { AuthenticatorTransportFuture, CredentialDeviceType, PublicKeyCredentialCreationOptionsJSON, - PublicKeyCredentialDescriptorFuture, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from '@simplewebauthn/types'; @@ -31,33 +29,33 @@ import type { @Injectable() export class WebAuthnService { constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.userSecurityKeysRepository) private userSecurityKeysRepository: UserSecurityKeysRepository, - - private metaService: MetaService, ) { } @bindThis - public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> { - const instance = await this.metaService.fetch(); + public getRelyingParty(): { origin: string; rpId: string; rpName: string; rpIcon?: string; } { return { origin: this.config.url, rpId: this.config.hostname, - rpName: instance.name ?? this.config.host, - rpIcon: instance.iconUrl ?? undefined, + rpName: this.meta.name ?? this.config.host, + rpIcon: this.meta.iconUrl ?? undefined, }; } @bindThis public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise { - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -104,7 +102,7 @@ export class WebAuthnService { await this.redisClient.del(`webauthn:challenge:${userId}`); - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); let verification; try { @@ -143,7 +141,7 @@ export class WebAuthnService { @bindThis public async initiateAuthentication(userId: MiUser['id']): Promise { - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -166,6 +164,86 @@ export class WebAuthnService { return authenticationOptions; } + /** + * Initiate Passkey Auth (Without specifying user) + * @returns authenticationOptions + */ + @bindThis + public async initiateSignInWithPasskeyAuthentication(context: string): Promise { + const relyingParty = await this.getRelyingParty(); + + const authenticationOptions = await generateAuthenticationOptions({ + rpID: relyingParty.rpId, + userVerification: 'preferred', + }); + + await this.redisClient.setex(`webauthn:challenge:${context}`, 90, authenticationOptions.challenge); + + return authenticationOptions; + } + + /** + * Verify Webauthn AuthenticationCredential + * @throws IdentifiableError + * @returns If the challenge is successful, return the user ID. Otherwise, return null. + */ + @bindThis + public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise { + const challenge = await this.redisClient.get(`webauthn:challenge:${context}`); + + if (!challenge) { + throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`); + } + + await this.redisClient.del(`webauthn:challenge:${context}`); + + const key = await this.userSecurityKeysRepository.findOneBy({ + id: response.id, + }); + + if (!key) { + throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'Unknown Webauthn key'); + } + + const relyingParty = await this.getRelyingParty(); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: response, + expectedChallenge: challenge, + expectedOrigin: relyingParty.origin, + expectedRPID: relyingParty.rpId, + authenticator: { + credentialID: key.id, + credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), + counter: key.counter, + transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, + }, + requireUserVerification: true, + }); + } catch (error) { + throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`); + } + + const { verified, authenticationInfo } = verification; + + if (!verified) { + return null; + } + + await this.userSecurityKeysRepository.update({ + id: response.id, + }, { + lastUsed: new Date(), + counter: authenticationInfo.newCounter, + credentialDeviceType: authenticationInfo.credentialDeviceType, + credentialBackedUp: authenticationInfo.credentialBackedUp, + }); + + return key.userId; + } + @bindThis public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise { const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`); @@ -209,7 +287,7 @@ export class WebAuthnService { } } - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); let verification; try { diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts new file mode 100644 index 000000000000..296acbf4614a --- /dev/null +++ b/packages/backend/src/core/WebhookTestService.ts @@ -0,0 +1,439 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { type WebhookEventTypes } from '@/models/Webhook.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; +import { QueueService } from '@/core/QueueService.js'; + +const oneDayMillis = 24 * 60 * 60 * 1000; + +function generateAbuseReport(override?: Partial): MiAbuseUserReport { + return { + id: 'dummy-abuse-report1', + targetUserId: 'dummy-target-user', + targetUser: null, + reporterId: 'dummy-reporter-user', + reporter: null, + assigneeId: null, + assignee: null, + resolved: false, + forwarded: false, + comment: 'This is a dummy report for testing purposes.', + targetUserHost: null, + reporterHost: null, + ...override, + }; +} + +function generateDummyUser(override?: Partial): MiUser { + return { + id: 'dummy-user-1', + updatedAt: new Date(Date.now() - oneDayMillis * 7), + lastFetchedAt: new Date(Date.now() - oneDayMillis * 5), + lastActiveDate: new Date(Date.now() - oneDayMillis * 3), + hideOnlineStatus: false, + username: 'dummy1', + usernameLower: 'dummy1', + name: 'DummyUser1', + followersCount: 10, + followingCount: 5, + movedToUri: null, + movedAt: null, + alsoKnownAs: null, + notesCount: 30, + avatarId: null, + avatar: null, + bannerId: null, + banner: null, + avatarUrl: null, + bannerUrl: null, + avatarBlurhash: null, + bannerBlurhash: null, + avatarDecorations: [], + tags: [], + isSuspended: false, + isLocked: false, + isBot: false, + isCat: true, + isRoot: false, + isExplorable: true, + isHibernated: false, + isDeleted: false, + emojis: [], + score: 0, + host: null, + inbox: null, + sharedInbox: null, + featured: null, + uri: null, + followersUri: null, + token: null, + ...override, + }; +} + +function generateDummyNote(override?: Partial): MiNote { + return { + id: 'dummy-note-1', + replyId: null, + reply: null, + renoteId: null, + renote: null, + threadId: null, + text: 'This is a dummy note for testing purposes.', + name: null, + cw: null, + userId: 'dummy-user-1', + user: null, + localOnly: true, + reactionAcceptance: 'likeOnly', + renoteCount: 10, + repliesCount: 5, + clippedCount: 0, + reactions: {}, + visibility: 'public', + uri: null, + url: null, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + mentionedRemoteUsers: '[]', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + channelId: null, + channel: null, + userHost: null, + replyUserId: null, + replyUserHost: null, + renoteUserId: null, + renoteUserHost: null, + createdAt: new Date(), + updatedAt: null, + updatedAtHistory: null, + noteEditHistory: [], + ...override, + }; +} + +function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> { + return { + id: note.id, + createdAt: new Date().toISOString(), + deletedAt: null, + text: note.text, + cw: note.cw, + userId: note.userId, + user: toPackedUserLite(note.user ?? generateDummyUser()), + replyId: note.replyId, + renoteId: note.renoteId, + isHidden: false, + visibility: note.visibility, + mentions: note.mentions, + visibleUserIds: note.visibleUserIds, + fileIds: note.fileIds, + files: [], + tags: note.tags, + poll: null, + emojis: note.emojis, + channelId: note.channelId, + channel: note.channel, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, + reactionEmojis: {}, + reactions: {}, + reactionCount: 0, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + reactionAndUserPairCache: note.reactionAndUserPairCache, + ...(detail ? { + clippedCount: note.clippedCount, + reply: note.reply ? toPackedNote(note.reply, false) : null, + renote: note.renote ? toPackedNote(note.renote, true) : null, + myReaction: null, + } : {}), + ...override, + }; +} + +function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> { + return { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.map(it => ({ + id: it.id, + angle: it.angle, + flipH: it.flipH, + url: 'https://example.com/dummy-image001.png', + offsetX: it.offsetX, + offsetY: it.offsetY, + })), + isBot: user.isBot, + isCat: user.isCat, + emojis: user.emojis, + onlineStatus: 'active', + badgeRoles: [], + ...override, + }; +} + +function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> { + return { + ...toPackedUserLite(user), + url: null, + uri: null, + movedTo: null, + alsoKnownAs: [], + createdAt: new Date().toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: false, + isSuspended: user.isSuspended, + description: null, + location: null, + birthday: null, + lang: null, + fields: [], + verifiedLinks: [], + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPageId: null, + pinnedPage: null, + publicReactions: true, + followersVisibility: 'public', + followingVisibility: 'public', + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + roles: [], + memo: null, + moderationNote: undefined, + isFollowing: false, + isFollowed: false, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isBlocking: false, + isBlocked: false, + isMuted: false, + isRenoteMuted: false, + notify: 'none', + withReplies: true, + ...override, + }; +} + +const dummyUser1 = generateDummyUser(); +const dummyUser2 = generateDummyUser({ + id: 'dummy-user-2', + updatedAt: new Date(Date.now() - oneDayMillis * 30), + lastFetchedAt: new Date(Date.now() - oneDayMillis), + lastActiveDate: new Date(Date.now() - oneDayMillis), + username: 'dummy2', + usernameLower: 'dummy2', + name: 'DummyUser2', + followersCount: 40, + followingCount: 50, + notesCount: 900, +}); +const dummyUser3 = generateDummyUser({ + id: 'dummy-user-3', + updatedAt: new Date(Date.now() - oneDayMillis * 15), + lastFetchedAt: new Date(Date.now() - oneDayMillis * 2), + lastActiveDate: new Date(Date.now() - oneDayMillis * 2), + username: 'dummy3', + usernameLower: 'dummy3', + name: 'DummyUser3', + followersCount: 60, + followingCount: 70, + notesCount: 15900, +}); + +@Injectable() +export class WebhookTestService { + public static NoSuchWebhookError = class extends Error {}; + + constructor( + private userWebhookService: UserWebhookService, + private systemWebhookService: SystemWebhookService, + private queueService: QueueService, + ) { + } + + /** + * UserWebhookのテスト送信を行う. + * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない. + * + * また、この関数経由で送信されるWebhookは以下の設定を無視する. + * - Webhookそのものの有効・無効設定(active) + * - 送信対象イベント(on)に関する設定 + */ + @bindThis + public async testUserWebhook( + params: { + webhookId: MiWebhook['id'], + type: WebhookEventTypes, + override?: Partial>, + }, + sender: MiUser | null, + ) { + const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] }) + .then(it => it.filter(it => it.userId === sender?.id)); + if (webhooks.length === 0) { + throw new WebhookTestService.NoSuchWebhookError(); + } + + const webhook = webhooks[0]; + const send = (contents: unknown) => { + const merged = { + ...webhook, + ...params.override, + }; + + // テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). + // また、Jobの試行回数も1回だけ. + this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 }); + }; + + const dummyNote1 = generateDummyNote({ + userId: dummyUser1.id, + user: dummyUser1, + }); + const dummyReply1 = generateDummyNote({ + id: 'dummy-reply-1', + replyId: dummyNote1.id, + reply: dummyNote1, + userId: dummyUser1.id, + user: dummyUser1, + }); + const dummyRenote1 = generateDummyNote({ + id: 'dummy-renote-1', + renoteId: dummyNote1.id, + renote: dummyNote1, + userId: dummyUser2.id, + user: dummyUser2, + text: null, + }); + const dummyMention1 = generateDummyNote({ + id: 'dummy-mention-1', + userId: dummyUser1.id, + user: dummyUser1, + text: `@${dummyUser2.username} This is a mention to you.`, + mentions: [dummyUser2.id], + }); + + switch (params.type) { + case 'note': { + send(toPackedNote(dummyNote1)); + break; + } + case 'reply': { + send(toPackedNote(dummyReply1)); + break; + } + case 'renote': { + send(toPackedNote(dummyRenote1)); + break; + } + case 'mention': { + send(toPackedNote(dummyMention1)); + break; + } + case 'follow': { + send(toPackedUserDetailedNotMe(dummyUser1)); + break; + } + case 'followed': { + send(toPackedUserLite(dummyUser2)); + break; + } + case 'unfollow': { + send(toPackedUserDetailedNotMe(dummyUser3)); + break; + } + } + } + + /** + * SystemWebhookのテスト送信を行う. + * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない. + * + * また、この関数経由で送信されるWebhookは以下の設定を無視する. + * - Webhookそのものの有効・無効設定(isActive) + * - 送信対象イベント(on)に関する設定 + */ + @bindThis + public async testSystemWebhook( + params: { + webhookId: MiSystemWebhook['id'], + type: SystemWebhookEventType, + override?: Partial>, + }, + ) { + const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] }); + if (webhooks.length === 0) { + throw new WebhookTestService.NoSuchWebhookError(); + } + + const webhook = webhooks[0]; + const send = (contents: unknown) => { + const merged = { + ...webhook, + ...params.override, + }; + + // テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). + // また、Jobの試行回数も1回だけ. + this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 }); + }; + + switch (params.type) { + case 'abuseReport': { + send(generateAbuseReport({ + targetUserId: dummyUser1.id, + targetUser: dummyUser1, + reporterId: dummyUser2.id, + reporter: dummyUser2, + })); + break; + } + case 'abuseReportResolved': { + send(generateAbuseReport({ + targetUserId: dummyUser1.id, + targetUser: dummyUser1, + reporterId: dummyUser2.id, + reporter: dummyUser2, + assigneeId: dummyUser3.id, + assignee: dummyUser3, + resolved: true, + })); + break; + } + case 'userCreated': { + send(toPackedUserLite(dummyUser1)); + break; + } + } + } +} diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index f6b70ead4455..4192e8659ad0 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -54,8 +54,8 @@ export class ApDbResolverService implements OnApplicationShutdown { private cacheService: CacheService, private apPersonService: ApPersonService, ) { - this.publicKeyCache = new MemoryKVCache(Infinity); - this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); + this.publicKeyCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h + this.publicKeyByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h } @bindThis diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1d93..cf8dd4605a26 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -14,17 +14,17 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -48,6 +48,9 @@ export class ApInboxService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -64,7 +67,6 @@ export class ApInboxService { private noteEntityService: NoteEntityService, private utilityService: UtilityService, private idService: IdService, - private metaService: MetaService, private abuseReportService: AbuseReportService, private userFollowingService: UserFollowingService, private apAudienceService: ApAudienceService, @@ -73,6 +75,7 @@ export class ApInboxService { private notePiningService: NotePiningService, private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private noteDeleteService: NoteDeleteService, private appLockService: AppLockService, private apResolverService: ApResolverService, @@ -289,9 +292,8 @@ export class ApInboxService { return; } - // アナウンス先をブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return; + // アナウンス先が許可されているかチェック + if (!this.utilityService.isFederationAllowedUri(uri)) return; const unlock = await this.appLockService.getApLock(uri); @@ -751,11 +753,13 @@ export class ApInboxService { @bindThis private async update(actor: MiRemoteUser, activity: IUpdate): Promise { + const uri = getApId(activity); + if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } - this.logger.debug('Update'); + this.logger.info(`Update: ${uri}`); const resolver = this.apResolverService.createResolver(); @@ -767,14 +771,72 @@ export class ApInboxService { if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri, resolver, object); return 'ok: Person updated'; + } else if (getApType(object) === 'Note') { + await this.updateNote(resolver, actor, object, false, activity); + return 'ok: Note updated'; } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); return 'ok: Question updated'; + } else if (isPost(object) && object.additionalCc) { + const uri = getApId(object); + const unlock = await this.appLockService.getApLock(uri); + + try { + const exist = await this.apNoteService.fetchNote(object); + if (exist && !await this.noteEntityService.isVisibleForMe(exist, object.additionalCc as string)) { + await this.noteCreateService.appendNoteVisibleUser(actor, exist, object.additionalCc as string); + return 'ok: note visible user appended'; + } else { + return 'skip: nothing to do'; + } + } catch (err) { + if (err instanceof StatusError && !err.isRetryable) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } } else { return `skip: Unknown type: ${getApType(object)}`; } } + @bindThis + private async updateNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: IUpdate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return 'skip: actor.uri !== note.attributedTo'; + } + + if (typeof note.id === 'string') { + if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { + return 'skip: host in actor.uri !== note.id'; + } + } + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + const target = await this.notesRepository.findOneBy({ uri: uri }); + if (!target) return `skip: target note not located: ${uri}`; + await this.apNoteService.updateNote(note, target, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + @bindThis private async move(actor: MiRemoteUser, activity: IMove): Promise { // fetch the new and old accounts diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 98e944f347a5..66e0c19dc804 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -108,6 +108,7 @@ export class ApRendererService { actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, object, @@ -438,6 +439,7 @@ export class ApRendererService { _misskey_quote: quote, quoteUrl: quote, published: this.idService.parse(note.id).date.toISOString(), + updated: note.updatedAt?.toISOString() ?? undefined, to, cc, inReplyTo, @@ -494,6 +496,7 @@ export class ApRendererService { name: user.name, summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, _misskey_summary: profile.description, + _misskey_followedMessage: profile.followedMessage, icon: avatar ? this.renderImage(avatar) : null, image: banner ? this.renderImage(banner) : null, tag, diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 93ac8ce9a74b..c7d19adfd5fa 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -6,6 +6,7 @@ import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; +import { Window } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -180,7 +181,8 @@ export class ApRequestService { * @param url URL to fetch */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }): Promise { + public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise { + const _followAlternate = followAlternate ?? true; const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = ApRequestCreator.createSignedGet({ @@ -198,9 +200,58 @@ export class ApRequestService { headers: req.request.headers, }, { throwErrorWhenResponseNotOk: true, - validators: [validateContentTypeSetAsActivityPub], }); + //#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき + const contentType = res.headers.get('content-type'); + + if ( + res.ok && + (contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && + _followAlternate === true + ) { + const html = await res.text(); + const { window, happyDOM } = new Window({ + settings: { + disableJavaScriptEvaluation: true, + disableJavaScriptFileLoading: true, + disableCSSFileLoading: true, + disableComputedStyleRendering: true, + handleDisabledFileLoadingAsSuccess: true, + navigation: { + disableMainFrameNavigation: true, + disableChildFrameNavigation: true, + disableChildPageNavigation: true, + disableFallbackToSetURL: true, + }, + timer: { + maxTimeout: 0, + maxIntervalTime: 0, + maxIntervalIterations: 0, + }, + }, + }); + const document = window.document; + try { + document.documentElement.innerHTML = html; + + const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); + if (alternate) { + const href = alternate.getAttribute('href'); + if (href) { + return await this.signedGet(href, user, false); + } + } + } catch (e) { + // something went wrong parsing the HTML, ignore the whole thing + } finally { + happyDOM.close().catch(err => {}); + } + } + //#endregion + + validateContentTypeSetAsActivityPub(res); + return await res.json(); } } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index bb3c40f0939b..ca35608d9bdc 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -7,9 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -29,6 +28,7 @@ export class Resolver { constructor( private config: Config, + private meta: MiMeta, private usersRepository: UsersRepository, private notesRepository: NotesRepository, private pollsRepository: PollsRepository, @@ -36,7 +36,6 @@ export class Resolver { private followRequestsRepository: FollowRequestsRepository, private utilityService: UtilityService, private instanceActorService: InstanceActorService, - private metaService: MetaService, private apRequestService: ApRequestService, private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, @@ -94,8 +93,7 @@ export class Resolver { return await this.resolveLocal(value); } - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { + if (!this.utilityService.isFederationAllowedHost(host)) { throw new Error('Instance is blocked'); } @@ -178,6 +176,9 @@ export class ApResolverService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -195,7 +196,6 @@ export class ApResolverService { private utilityService: UtilityService, private instanceActorService: InstanceActorService, - private metaService: MetaService, private apRequestService: ApRequestService, private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, @@ -208,6 +208,7 @@ export class ApResolverService { public createResolver(): Resolver { return new Resolver( this.config, + this.meta, this.usersRepository, this.notesRepository, this.pollsRepository, @@ -215,7 +216,6 @@ export class ApResolverService { this.followRequestsRepository, this.utilityService, this.instanceActorService, - this.metaService, this.apRequestService, this.httpRequestService, this.apRendererService, diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index feb8c42c563c..3dd85b9b86c5 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -554,6 +554,7 @@ const extension_context_definition = { '_misskey_reaction': 'misskey:_misskey_reaction', '_misskey_votes': 'misskey:_misskey_votes', '_misskey_summary': 'misskey:_misskey_summary', + '_misskey_followedMessage': 'misskey:_misskey_followedMessage', 'isCat': 'misskey:isCat', // vcard vcard: 'http://www.w3.org/2006/vcard/ns#', diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 369196727014..e7ece87b01e1 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -5,10 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiMeta } from '@/models/_.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { MetaService } from '@/core/MetaService.js'; import { truncate } from '@/misc/truncate.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { DriveService } from '@/core/DriveService.js'; @@ -24,10 +23,12 @@ export class ApImageService { private logger: Logger; constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private metaService: MetaService, private apResolverService: ApResolverService, private driveService: DriveService, private apLoggerService: ApLoggerService, @@ -63,12 +64,10 @@ export class ApImageService { this.logger.info(`Creating the Image: ${image.url}`); - const instance = await this.metaService.fetch(); - // Cache if remote file cache is on AND either // 1. remote sensitive file is also on // 2. or the image is not sensitive - const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive); + const shouldBeCached = this.meta.cacheRemoteFiles && (this.meta.cacheRemoteSensitiveFiles || !image.sensitive); const file = await this.driveService.uploadFromUrl({ url: image.url, diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index fc7aa1e0b972..8e24d6b90371 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -6,13 +6,12 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository } from '@/models/_.js'; +import type { NotesRepository, PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; import type { MiEmoji } from '@/models/Emoji.js'; -import { MetaService } from '@/core/MetaService.js'; import { AppLockService } from '@/core/AppLockService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; @@ -23,6 +22,7 @@ import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; @@ -46,12 +46,18 @@ export class ApNoteService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private apMfmService: ApMfmService, private apResolverService: ApResolverService, @@ -65,10 +71,10 @@ export class ApNoteService { private apMentionService: ApMentionService, private apImageService: ApImageService, private apQuestionService: ApQuestionService, - private metaService: MetaService, private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -78,9 +84,10 @@ export class ApNoteService { @bindThis public validateNote(object: IObject, uri: string): Error | null { const expectHost = this.utilityService.extractDbHost(uri); + const apType = getApType(object); - if (!validPost.includes(getApType(object))) { - return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${getApType(object)}`); + if (apType == null || !validPost.includes(apType)) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`); } if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { @@ -154,7 +161,7 @@ export class ApNoteService { const uri = getOneApId(note.attributedTo); // ローカルで投稿者を検索し、もし凍結されていたらスキップ - const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser; + const cachedActor = await this.apPersonService.fetchPerson(uri) as (MiRemoteUser | null); if (cachedActor && cachedActor.isSuspended) { throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended'); } @@ -181,7 +188,7 @@ export class ApNoteService { /** * 禁止ワードチェック */ - const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); + const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); if (hasProhibitedWords) { throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); } @@ -228,6 +235,10 @@ export class ApNoteService { }) .catch(async err => { this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + if (err?.statusCode === 401 || err?.statusCode === 403 || err?.statusCode === 404) { + this.logger.info('Set inReplyTo to null'); + return null; + } throw err; }) : null; @@ -295,6 +306,7 @@ export class ApNoteService { try { return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, + updatedAt: note.updated ? new Date(note.updated) : null, files, reply, renote: quote, @@ -324,6 +336,85 @@ export class ApNoteService { } } + @bindThis + public async updateNote(value: string | IObject, target: MiNote, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); + const entryUri = getApId(value); + + const err = this.validateNote(object, entryUri); + if (err) { + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, + }); + throw new Error('invalid note'); + } + + const note = object as IPost; + + // 投稿者をフェッチ + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } + + const cw = note.summary === '' ? null : note.summary; + + // テキストのパース + let text: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { + text = note.source.content; + } else if (typeof note._misskey_content !== 'undefined') { + text = note._misskey_content; + } else if (typeof note.content === 'string') { + text = this.apMfmService.htmlToMfm(note.content, note.tag); + } + + const apHashtags = extractApHashtags(note.tag); + + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return []; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + try { + return await this.noteUpdateService.update(actor, { + updatedAt: note.updated ? new Date(note.updated) : null, + files, + name: note.name, + cw, + text, + apHashtags, + apEmojis, + poll, + }, target, silent); + } catch (err: any) { + this.logger.warn(`note update failed: ${err}`); + return err; + } + } + /** * Noteを解決します。 * @@ -334,9 +425,7 @@ export class ApNoteService { public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise { const uri = getApId(value); - // ブロックしていたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) { + if (!this.utilityService.isFederationAllowedUri(uri)) { throw new StatusError('blocked host', 451); } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 457205e0238e..b1a28e69929d 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -8,7 +8,7 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; +import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js'; @@ -35,7 +35,6 @@ import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; @@ -46,9 +45,10 @@ import type { ApNoteService } from './ApNoteService.js'; import type { ApMfmService } from '../ApMfmService.js'; import type { ApResolverService, Resolver } from '../ApResolverService.js'; import type { ApLoggerService } from '../ApLoggerService.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports + import type { ApImageService } from './ApImageService.js'; -import type { IActor, IObject } from '../type.js'; +import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; const nameLength = 128; const summaryLength = 2048; @@ -62,7 +62,6 @@ export class ApPersonService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private idService: IdService; private globalEventService: GlobalEventService; - private metaService: MetaService; private federatedInstanceService: FederatedInstanceService; private fetchInstanceMetadataService: FetchInstanceMetadataService; private cacheService: CacheService; @@ -84,6 +83,9 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.db) private db: DataSource, @@ -103,6 +105,8 @@ export class ApPersonService implements OnModuleInit { private followingsRepository: FollowingsRepository, private roleService: RoleService, + + private avatarDecorationService: AvatarDecorationService, ) { } @@ -112,7 +116,6 @@ export class ApPersonService implements OnModuleInit { this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.idService = this.moduleRef.get('IdService'); this.globalEventService = this.moduleRef.get('GlobalEventService'); - this.metaService = this.moduleRef.get('MetaService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); this.cacheService = this.moduleRef.get('CacheService'); @@ -296,6 +299,21 @@ export class ApPersonService implements OnModuleInit { const isBot = getApType(object) === 'Service' || getApType(object) === 'Application'; + const [followingVisibility, followersVisibility] = await Promise.all( + [ + this.isPublicCollection(person.following, resolver), + this.isPublicCollection(person.followers, resolver), + ].map((p): Promise<'public' | 'private'> => p + .then(isPublic => isPublic ? 'public' : 'private') + .catch(err => { + if (!(err instanceof StatusError) || err.isRetryable) { + this.logger.error('error occurred while fetching following/followers collection', { stack: err }); + } + return 'private'; + }), + ), + ); + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const url = getOneApHrefNullable(person.url); @@ -355,8 +373,11 @@ export class ApPersonService implements OnModuleInit { await transactionalEntityManager.save(new MiUserProfile({ userId: user.id, description: _description, + followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, url, fields, + followingVisibility, + followersVisibility, birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, userHost: host, @@ -390,10 +411,10 @@ export class ApPersonService implements OnModuleInit { this.cacheService.uriPersonCache.set(user.uri, user); // Register host - this.federatedInstanceService.fetch(host).then(async i => { + this.federatedInstanceService.fetch(host).then(i => { this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.newUser(i.host); } }); @@ -403,6 +424,8 @@ export class ApPersonService implements OnModuleInit { // ハッシュタグ更新 this.hashtagService.updateUsertags(user, tags); + this.avatarDecorationService.remoteUserUpdate(user); + //#region アバターとヘッダー画像をフェッチ try { const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image); @@ -464,6 +487,23 @@ export class ApPersonService implements OnModuleInit { const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); + const [followingVisibility, followersVisibility] = await Promise.all( + [ + this.isPublicCollection(person.following, resolver), + this.isPublicCollection(person.followers, resolver), + ].map((p): Promise<'public' | 'private' | undefined> => p + .then(isPublic => isPublic ? 'public' : 'private') + .catch(err => { + if (!(err instanceof StatusError) || err.isRetryable) { + this.logger.error('error occurred while fetching following/followers collection', { stack: err }); + // Do not update the visibiility on transient errors. + return undefined; + } + return 'private'; + }), + ), + ); + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const url = getOneApHrefNullable(person.url); @@ -511,6 +551,8 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user + const user = await this.usersRepository.findOneByOrFail({ id: exist.id }); + await this.avatarDecorationService.remoteUserUpdate(user); await this.usersRepository.update(exist.id, updates); if (person.publicKey) { @@ -532,6 +574,9 @@ export class ApPersonService implements OnModuleInit { url, fields, description: _description, + followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, + followingVisibility, + followersVisibility, birthday: bday?.[0] ?? null, location: person['vcard:Address'] ?? null, }); @@ -703,4 +748,16 @@ export class ApPersonService implements OnModuleInit { return 'ok'; } + + @bindThis + private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise { + if (collection) { + const resolved = await resolver.resolveCollection(collection); + if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) { + return true; + } + } + + return false; + } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 5b6c6c8ca6cb..c93d419ce259 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -13,8 +13,11 @@ export interface IObject { name?: string | null; summary?: string; _misskey_summary?: string; + _misskey_followedMessage?: string | null; published?: string; + updated?: string; cc?: ApObject; + additionalCc?: ApObject, to?: ApObject; attributedTo?: ApObject; attachment?: any[]; @@ -60,11 +63,14 @@ export function getApId(value: string | IObject): string { /** * Get ActivityStreams Object type + * + * タイプ判定ができなかった場合に、あえてエラーではなくnullを返すようにしている。 + * 詳細: https://github.com/misskey-dev/misskey/issues/14239 */ -export function getApType(value: IObject): string { +export function getApType(value: IObject): string | null { if (typeof value.type === 'string') return value.type; if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0]; - throw new Error('cannot detect type'); + return null; } export function getOneApHrefNullable(value: ApObject | undefined): string | undefined { @@ -97,19 +103,23 @@ export interface IActivity extends IObject { export interface ICollection extends IObject { type: 'Collection'; totalItems: number; - items: ApObject; + first?: IObject | string; + items?: ApObject; } export interface IOrderedCollection extends IObject { type: 'OrderedCollection'; totalItems: number; - orderedItems: ApObject; + first?: IObject | string; + orderedItems?: ApObject; } export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; -export const isPost = (object: IObject): object is IPost => - validPost.includes(getApType(object)); +export const isPost = (object: IObject): object is IPost => { + const type = getApType(object); + return type != null && validPost.includes(type); +}; export interface IPost extends IObject { type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event'; @@ -156,8 +166,10 @@ export const isTombstone = (object: IObject): object is ITombstone => export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; -export const isActor = (object: IObject): object is IActor => - validActor.includes(getApType(object)); +export const isActor = (object: IObject): object is IActor => { + const type = getApType(object); + return type != null && validActor.includes(type); +}; export interface IActor extends IObject { type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; @@ -240,12 +252,16 @@ export interface IKey extends IObject { publicKeyPem: string | Buffer; } +export const validDocumentTypes = ['Audio', 'Document', 'Image', 'Page', 'Video']; + export interface IApDocument extends IObject { type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video'; } -export const isDocument = (object: IObject): object is IApDocument => - ['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object)); +export const isDocument = (object: IObject): object is IApDocument => { + const type = getApType(object); + return type != null && validDocumentTypes.includes(type); +}; export interface IApImage extends IApDocument { type: 'Image'; @@ -323,7 +339,10 @@ export const isAccept = (object: IObject): object is IAccept => getApType(object export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject'; export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add'; export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove'; -export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact'; +export const isLike = (object: IObject): object is ILike => { + const type = getApType(object); + return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type); +}; export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index c2329a2f7357..c9b43cc66d7c 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -5,10 +5,9 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import type { FollowingsRepository, InstancesRepository } from '@/models/_.js'; +import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js'; import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; @@ -24,13 +23,15 @@ export default class FederationChart extends Chart { // eslint-di @Inject(DI.db) private db: DataSource, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private metaService: MetaService, private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { @@ -43,8 +44,6 @@ export default class FederationChart extends Chart { // eslint-di } protected async tickMinor(): Promise>> { - const meta = await this.metaService.fetch(); - const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') .select('instance.host') .where('instance.suspensionState != \'none\''); @@ -65,21 +64,21 @@ export default class FederationChart extends Chart { // eslint-di this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followerHost)') .where('following.followerHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .setParameters(pubsubSubQuery.getParameters()) @@ -88,7 +87,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() @@ -96,7 +95,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index d110f7afc632..4aa7104c1e32 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -49,6 +49,7 @@ export class FlashEntityService { title: flash.title, summary: flash.summary, script: flash.script, + visibility: flash.visibility, likedCount: flash.likedCount, isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined, }); diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 4c45c131679a..284537b9861f 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -3,19 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import type { MiInstance } from '@/models/Instance.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { RoleService } from '@/core/RoleService.js'; import { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { MiMeta } from '@/models/_.js'; @Injectable() export class InstanceEntityService { constructor( - private metaService: MetaService, + @Inject(DI.meta) + private meta: MiMeta, + private roleService: RoleService, private utilityService: UtilityService, @@ -27,7 +30,6 @@ export class InstanceEntityService { instance: MiInstance, me?: { id: MiUser['id']; } | null | undefined, ): Promise> { - const meta = await this.metaService.fetch(); const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; return { @@ -41,7 +43,7 @@ export class InstanceEntityService { isNotResponding: instance.isNotResponding, isSuspended: instance.suspensionState !== 'none', suspensionState: instance.suspensionState, - isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host), + isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, openRegistrations: instance.openRegistrations, @@ -49,8 +51,8 @@ export class InstanceEntityService { description: instance.description, maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, - isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host), - isMediaSilenced: this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, instance.host), + isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host), + isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host), iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, @@ -63,8 +65,9 @@ export class InstanceEntityService { @bindThis public packMany( instances: MiInstance[], + me?: { id: MiUser['id']; } | null | undefined, ) { - return Promise.all(instances.map(x => this.pack(x))); + return Promise.all(instances.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 44ec0d6a7bc1..fbd982eb34f8 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -10,7 +10,6 @@ import type { Packed } from '@/misc/json-schema.js'; import type { MiMeta } from '@/models/Meta.js'; import type { AdsRepository } from '@/models/_.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; @@ -24,11 +23,13 @@ export class MetaEntityService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.adsRepository) private adsRepository: AdsRepository, private userEntityService: UserEntityService, - private metaService: MetaService, private instanceActorService: InstanceActorService, ) { } @@ -37,7 +38,7 @@ export class MetaEntityService { let instance = meta; if (!instance) { - instance = await this.metaService.fetch(); + instance = this.meta; } const ads = await this.adsRepository.createQueryBuilder('ads') @@ -129,6 +130,7 @@ export class MetaEntityService { mediaProxy: this.config.mediaProxy, enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', + maxFileSize: this.config.maxFileSize, }; return packed; @@ -139,7 +141,7 @@ export class MetaEntityService { let instance = meta; if (!instance) { - instance = await this.metaService.fetch(); + instance = this.meta; } const packed = await this.pack(instance); diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2cd092231cf5..6e12cc85ae0e 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,11 +11,11 @@ import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiNoteReaction } from '@/models/NoteReaction.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -28,12 +28,16 @@ export class NoteEntityService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private customEmojiService: CustomEmojiService; private reactionService: ReactionService; + private reactionsBufferingService: ReactionsBufferingService; private idService: IdService; private noteLoader = new DebounceLoader(this.findNoteOrFail); constructor( private moduleRef: ModuleRef, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -59,6 +63,8 @@ export class NoteEntityService implements OnModuleInit { //private driveFileEntityService: DriveFileEntityService, //private customEmojiService: CustomEmojiService, //private reactionService: ReactionService, + //private reactionsBufferingService: ReactionsBufferingService, + //private idService: IdService, ) { } @@ -67,6 +73,7 @@ export class NoteEntityService implements OnModuleInit { this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.reactionService = this.moduleRef.get('ReactionService'); + this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService'); this.idService = this.moduleRef.get('IdService'); } @@ -287,6 +294,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; withReactionAndUserPairCache?: boolean; _hint_?: { + bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; packedFiles: Map | null>; packedUsers: Map> @@ -303,6 +311,15 @@ export class NoteEntityService implements OnModuleInit { const note = typeof src === 'object' ? src : await this.noteLoader.load(src); const host = note.userHost; + const bufferedReactions = opts._hint_?.bufferedReactions != null + ? (opts._hint_.bufferedReactions.get(note.id) ?? { deltas: {}, pairs: [] }) + : this.meta.enableReactionsBuffering + ? await this.reactionsBufferingService.get(note.id) + : { deltas: {}, pairs: [] }; + const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {})); + + const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/'))); + let text = note.text; if (note.name && (note.url ?? note.uri)) { @@ -315,7 +332,7 @@ export class NoteEntityService implements OnModuleInit { : await this.channelsRepository.findOneBy({ id: note.channelId }) : null; - const reactionEmojiNames = Object.keys(note.reactions) + const reactionEmojiNames = Object.keys(reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packedFiles = options?._hint_?.packedFiles; @@ -324,6 +341,7 @@ export class NoteEntityService implements OnModuleInit { const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), + updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, @@ -334,10 +352,10 @@ export class NoteEntityService implements OnModuleInit { visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, - reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0), - reactions: this.reactionService.convertLegacyReactions(note.reactions), + reactionCount: Object.values(reactions).reduce((a, b) => a + b, 0), + reactions: reactions, reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), - reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, + reactionAndUserPairCache: opts.withReactionAndUserPairCache ? reactionAndUserPairCache : undefined, emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, @@ -376,8 +394,12 @@ export class NoteEntityService implements OnModuleInit { poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, - ...(meId && Object.keys(note.reactions).length > 0 ? { - myReaction: this.populateMyReaction(note, meId, options?._hint_), + ...(meId && Object.keys(reactions).length > 0 ? { + myReaction: this.populateMyReaction({ + id: note.id, + reactions: reactions, + reactionAndUserPairCache: reactionAndUserPairCache, + }, meId, options?._hint_), } : {}), } : {}), }); @@ -400,6 +422,8 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null; + const meId = me ? me.id : null; const myReactionsMap = new Map(); if (meId) { @@ -410,23 +434,33 @@ export class NoteEntityService implements OnModuleInit { for (const note of notes) { if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote - const reactionsCount = Object.values(note.renote.reactions).reduce((a, b) => a + b, 0); + const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.renote.id, null); - } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length) { - const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); + } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferedReactions?.get(note.renote.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.renote.id, pairInBuffer[1]); + } else { + const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null); + } } else { idsNeedFetchMyReaction.add(note.renote.id); } } else { if (note.id < oldId) { - const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); + const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.id, null); - } else if (reactionsCount <= note.reactionAndUserPairCache.length) { - const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); - myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); + } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); + if (pairInBuffer) { + myReactionsMap.set(note.id, pairInBuffer[1]); + } else { + const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId)); + myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null); + } } else { idsNeedFetchMyReaction.add(note.id); } @@ -461,6 +495,7 @@ export class NoteEntityService implements OnModuleInit { return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { + bufferedReactions, myReactions: myReactionsMap, packedFiles, packedUsers, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index f393513510e4..dff6968f9c9a 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types + options: { checkValidNotifier?: boolean; }, @@ -159,9 +159,16 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'roleAssigned' ? { role: role, } : {}), + ...(notification.type === 'followRequestAccepted' ? { + message: notification.message, + } : {}), ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), + ...(notification.type === 'exportCompleted' ? { + exportedEntity: notification.exportedEntity, + fileId: notification.fileId, + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader, @@ -229,7 +236,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 7fd093c1913a..6a59b9733519 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -454,12 +454,12 @@ export class UserEntityService implements OnModuleInit { } const followingCount = profile == null ? null : - (profile.followingVisibility === 'public') || isMe ? user.followingCount : + (profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount : (profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount : null; const followersCount = profile == null ? null : - (profile.followersVisibility === 'public') || isMe ? user.followersCount : + (profile.followersVisibility === 'public') || isMe || iAmModerator ? user.followersCount : (profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : null; @@ -480,7 +480,7 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ + avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll(false, true).then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ id: ud.id, angle: ud.angle || undefined, flipH: ud.flipH || undefined, @@ -508,7 +508,7 @@ export class UserEntityService implements OnModuleInit { name: r.name, iconUrl: r.iconUrl, displayOrder: r.displayOrder, - })) + })), ) : undefined, ...(isDetailed ? { @@ -567,6 +567,7 @@ export class UserEntityService implements OnModuleInit { ...(isDetailed && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, + followedMessage: profile!.followedMessage, isModerator: isModerator, isAdmin: isAdmin, injectFeaturedNote: profile!.injectFeaturedNote, @@ -635,6 +636,7 @@ export class UserEntityService implements OnModuleInit { isRenoteMuted: relation.isRenoteMuted, notify: relation.following?.notify ?? 'none', withReplies: relation.following?.withReplies ?? false, + followedMessage: relation.isFollowing ? profile!.followedMessage : undefined, } : {}), } as Promiseable>; diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 2c70344c941a..d229efb12346 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -3,13 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import si from 'systeminformation'; import Xev from 'xev'; import * as osUtils from 'os-utils'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; const ev = new Xev(); @@ -23,7 +24,8 @@ export class ServerStatsService implements OnApplicationShutdown { private intervalId: NodeJS.Timeout | null = null; constructor( - private metaService: MetaService, + @Inject(DI.meta) + private meta: MiMeta, ) { } @@ -32,7 +34,7 @@ export class ServerStatsService implements OnApplicationShutdown { */ @bindThis public async start(): Promise { - if (!(await this.metaService.fetch(true)).enableServerMachineStats) return; + if (!this.meta.enableServerMachineStats) return; const log = [] as any[]; diff --git a/packages/backend/src/decorators.ts b/packages/backend/src/decorators.ts index 21777657d185..42f925e1251c 100644 --- a/packages/backend/src/decorators.ts +++ b/packages/backend/src/decorators.ts @@ -10,8 +10,9 @@ * The getter will return a .bind version of the function * and memoize the result against a symbol on the instance */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function bindThis(target: any, key: string, descriptor: any) { - let fn = descriptor.value; + const fn = descriptor.value; if (typeof fn !== 'function') { throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`); @@ -21,26 +22,18 @@ export function bindThis(target: any, key: string, descriptor: any) { configurable: true, get() { // eslint-disable-next-line no-prototype-builtins - if (this === target.prototype || this.hasOwnProperty(key) || - typeof fn !== 'function') { + if (this === target.prototype || this.hasOwnProperty(key)) { return fn; } const boundFn = fn.bind(this); - Object.defineProperty(this, key, { + Reflect.defineProperty(this, key, { + value: boundFn, configurable: true, - get() { - return boundFn; - }, - set(value) { - fn = value; - delete this[key]; - }, + writable: true, }); + return boundFn; }, - set(value: any) { - fn = value; - }, }; } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 271082b4ff35..e599fc7b3737 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -6,11 +6,13 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), + meta: Symbol('meta'), meilisearch: Symbol('meilisearch'), redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), redisForTimelines: Symbol('redisForTimelines'), + redisForReactions: Symbol('redisForReactions'), //#region Repositories usersRepository: Symbol('usersRepository'), diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index bba64a06eff2..f9692ce5d5f3 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -7,23 +7,23 @@ import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; export class RedisKVCache { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemoryKVCache; - private fetcher: (key: string) => Promise; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; - - constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { - lifetime: RedisKVCache['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisKVCache['fetcher']; - toRedisConverter: RedisKVCache['toRedisConverter']; - fromRedisConverter: RedisKVCache['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; + private readonly lifetime: number; + private readonly memoryCache: MemoryKVCache; + private readonly fetcher: (key: string) => Promise; + private readonly toRedisConverter: (value: T) => string; + private readonly fromRedisConverter: (value: string) => T | undefined; + + constructor( + private redisClient: Redis.Redis, + private name: string, + opts: { + lifetime: RedisKVCache['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisKVCache['fetcher']; + toRedisConverter: RedisKVCache['toRedisConverter']; + fromRedisConverter: RedisKVCache['fromRedisConverter']; + }, + ) { this.lifetime = opts.lifetime; this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.fetcher = opts.fetcher; @@ -55,7 +55,13 @@ export class RedisKVCache { const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); if (cached == null) return undefined; - return this.fromRedisConverter(cached); + + const value = this.fromRedisConverter(cached); + if (value !== undefined) { + this.memoryCache.set(key, value); + } + + return value; } @bindThis @@ -66,6 +72,10 @@ export class RedisKVCache { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons: + * * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster. + * * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value. + * * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses. */ @bindThis public async fetch(key: string): Promise { @@ -77,14 +87,14 @@ export class RedisKVCache { // Cache MISS const value = await this.fetcher(key); - this.set(key, value); + await this.set(key, value); return value; } @bindThis public async refresh(key: string) { const value = await this.fetcher(key); - this.set(key, value); + await this.set(key, value); // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする } @@ -101,23 +111,23 @@ export class RedisKVCache { } export class RedisSingleCache { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemorySingleCache; - private fetcher: () => Promise; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; - - constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { - lifetime: RedisSingleCache['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisSingleCache['fetcher']; - toRedisConverter: RedisSingleCache['toRedisConverter']; - fromRedisConverter: RedisSingleCache['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; + private readonly lifetime: number; + private readonly memoryCache: MemorySingleCache; + private readonly fetcher: () => Promise; + private readonly toRedisConverter: (value: T) => string; + private readonly fromRedisConverter: (value: string) => T | undefined; + + constructor( + private redisClient: Redis.Redis, + private name: string, + opts: { + lifetime: number; + memoryCacheLifetime: number; + fetcher: RedisSingleCache['fetcher']; + toRedisConverter: RedisSingleCache['toRedisConverter']; + fromRedisConverter: RedisSingleCache['fromRedisConverter']; + }, + ) { this.lifetime = opts.lifetime; this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.fetcher = opts.fetcher; @@ -149,7 +159,13 @@ export class RedisSingleCache { const cached = await this.redisClient.get(`singlecache:${this.name}`); if (cached == null) return undefined; - return this.fromRedisConverter(cached); + + const value = this.fromRedisConverter(cached); + if (value !== undefined) { + this.memoryCache.set(value); + } + + return value; } @bindThis @@ -160,6 +176,10 @@ export class RedisSingleCache { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons: + * * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster. + * * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value. + * * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses. */ @bindThis public async fetch(): Promise { @@ -171,14 +191,14 @@ export class RedisSingleCache { // Cache MISS const value = await this.fetcher(); - this.set(value); + await this.set(value); return value; } @bindThis public async refresh() { const value = await this.fetcher(); - this.set(value); + await this.set(value); // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする } @@ -187,22 +207,12 @@ export class RedisSingleCache { // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? export class MemoryKVCache { - /** - * データを持つマップ - * @deprecated これを直接操作するべきではない - */ - public cache: Map; - private lifetime: number; - private gcIntervalHandle: NodeJS.Timeout; + private readonly cache = new Map(); + private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m - constructor(lifetime: MemoryKVCache['lifetime']) { - this.cache = new Map(); - this.lifetime = lifetime; - - this.gcIntervalHandle = setInterval(() => { - this.gc(); - }, 1000 * 60 * 3); - } + constructor( + private readonly lifetime: number, + ) {} @bindThis /** @@ -287,10 +297,14 @@ export class MemoryKVCache { @bindThis public gc(): void { const now = Date.now(); + for (const [key, { date }] of this.cache.entries()) { - if ((now - date) > this.lifetime) { - this.cache.delete(key); - } + // The map is ordered from oldest to youngest. + // We can stop once we find an entry that's still active, because all following entries must *also* be active. + const age = now - date; + if (age < this.lifetime) break; + + this.cache.delete(key); } } @@ -298,16 +312,19 @@ export class MemoryKVCache { public dispose(): void { clearInterval(this.gcIntervalHandle); } + + public get entries() { + return this.cache.entries(); + } } export class MemorySingleCache { private cachedAt: number | null = null; private value: T | undefined; - private lifetime: number; - constructor(lifetime: MemorySingleCache['lifetime']) { - this.lifetime = lifetime; - } + constructor( + private lifetime: number, + ) {} @bindThis public set(value: T): void { diff --git a/packages/backend/src/misc/collapsed-queue.ts b/packages/backend/src/misc/collapsed-queue.ts new file mode 100644 index 000000000000..5bc20a78ae29 --- /dev/null +++ b/packages/backend/src/misc/collapsed-queue.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type Job = { + value: V; + timer: NodeJS.Timeout; +}; + +// TODO: redis使えるようにする +export class CollapsedQueue { + private jobs: Map> = new Map(); + + constructor( + private timeout: number, + private collapse: (oldValue: V, newValue: V) => V, + private perform: (key: K, value: V) => Promise, + ) {} + + enqueue(key: K, value: V) { + if (this.jobs.has(key)) { + const old = this.jobs.get(key)!; + const merged = this.collapse(old.value, value); + this.jobs.set(key, { ...old, value: merged }); + } else { + const timer = setTimeout(() => { + const job = this.jobs.get(key)!; + this.jobs.delete(key); + this.perform(key, job.value); + }, this.timeout); + this.jobs.set(key, { value, timer }); + } + } + + async performAllNow() { + const entries = [...this.jobs.entries()]; + this.jobs.clear(); + for (const [_key, job] of entries) { + clearTimeout(job.timer); + } + await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value))); + } +} diff --git a/packages/backend/src/misc/fastify-hook-handlers.ts b/packages/backend/src/misc/fastify-hook-handlers.ts index 3e1c099e000c..fa3ef0a267d6 100644 --- a/packages/backend/src/misc/fastify-hook-handlers.ts +++ b/packages/backend/src/misc/fastify-hook-handlers.ts @@ -8,7 +8,7 @@ import type { onRequestHookHandler } from 'fastify'; export const handleRequestRedirectToOmitSearch: onRequestHookHandler = (request, reply, done) => { const index = request.url.indexOf('?'); if (~index) { - reply.redirect(301, request.url.slice(0, index)); + reply.redirect(request.url.slice(0, index), 301); } done(); }; diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index a721b8663c3c..040e36228ccf 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -144,7 +144,9 @@ export interface Schema extends OfSchema { readonly type?: TypeStringef; readonly nullable?: boolean; readonly optional?: boolean; + readonly prefixItems?: ReadonlyArray; readonly items?: Schema; + readonly unevaluatedItems?: Schema | boolean; readonly properties?: Obj; readonly required?: ReadonlyArray, string>>; readonly description?: string; @@ -198,6 +200,7 @@ type UnionSchemaType = X //type UnionObjectSchemaType = X extends any ? ObjectSchemaType : never; type UnionObjType = a[number]> = X extends any ? ObjType : never; type ArrayUnion = T extends any ? Array : never; +type ArrayToTuple> = { [K in keyof X]: SchemaType }; type ObjectSchemaTypeDef

= p['ref'] extends keyof typeof refs ? Packed : @@ -232,6 +235,12 @@ export type SchemaTypeDef

= p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : never ) : + p['prefixItems'] extends ReadonlyArray ? ( + p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : + p['items'] extends false ? ArrayToTuple : + p['unevaluatedItems'] extends false ? ArrayToTuple : + [...ArrayToTuple, ...unknown[]] + ) : p['items'] extends NonNullable ? SchemaType[] : any[] ) : diff --git a/packages/backend/src/misc/json-value.ts b/packages/backend/src/misc/json-value.ts index 79944417915e..bd7fe12058a0 100644 --- a/packages/backend/src/misc/json-value.ts +++ b/packages/backend/src/misc/json-value.ts @@ -6,3 +6,7 @@ export type JsonValue = JsonArray | JsonObject | string | number | boolean | null; export type JsonObject = {[K in string]?: JsonValue}; export type JsonArray = JsonValue[]; + +export function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts index 13f0b0566740..91fd9601b2e7 100644 --- a/packages/backend/src/models/AvatarDecoration.ts +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -36,4 +36,14 @@ export class MiAvatarDecoration { array: true, length: 128, default: '{}', }) public roleIdsThatCanBeUsedThisDecoration: string[]; + + @Column('varchar', { + length: 32, + }) + public remoteId: string; + + @Column('varchar', { + length: 128, nullable: true, + }) + public host: string | null; } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 70d41801b5ee..d29689f9073a 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -589,6 +589,11 @@ export class MiMeta { }) public perUserListTimelineCacheMax: number; + @Column('boolean', { + default: false, + }) + public enableReactionsBuffering: boolean; + @Column('integer', { default: 0, }) @@ -625,4 +630,17 @@ export class MiMeta { nullable: true, }) public urlPreviewUserAgent: string | null; + + @Column('varchar', { + length: 128, + default: 'all', + }) + public federation: 'all' | 'specified' | 'none'; + + @Column('varchar', { + length: 1024, + array: true, + default: '{}', + }) + public federationHosts: string[]; } diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab9b..759ff78094ba 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -15,6 +15,26 @@ export class MiNote { @PrimaryColumn(id()) public id: string; + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The last updated date of the Note.', + default: null, + }) + public updatedAt: Date | null; + + @Column('timestamp with time zone', { + comment: 'The history of when the note is updated', + array: true, + default: null, + }) + public updatedAtHistory: Date[] | null; + @Index() @Column({ ...id(), @@ -55,6 +75,12 @@ export class MiNote { }) public text: string | null; + @Column('text', { + array: true, + default: '{}', + }) + public noteEditHistory: string[]; + @Column('varchar', { length: 256, nullable: true, }) diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index df88b996364c..c1d3d42134d4 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -7,6 +7,8 @@ import { MiUser } from './User.js'; import { MiNote } from './Note.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; +import { MiDriveFile } from './DriveFile.js'; +import { userExportableEntities } from '@/types.js'; export type MiNotification = { type: 'note'; @@ -67,6 +69,7 @@ export type MiNotification = { id: string; createdAt: string; notifierId: MiUser['id']; + message: string | null; } | { type: 'roleAssigned'; id: string; @@ -77,6 +80,12 @@ export type MiNotification = { id: string; createdAt: string; achievement: string; +} | { + type: 'exportCompleted'; + id: string; + createdAt: string; + exportedEntity: typeof userExportableEntities[number]; + fileId: MiDriveFile['id']; } | { type: 'app'; id: string; @@ -85,7 +94,7 @@ export type MiNotification = { /** * アプリ通知のbody */ - customBody: string | null; + customBody: string; /** * アプリ通知のheader diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 9e2d7a34447d..61fd12ec7fe2 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -147,6 +147,7 @@ export class MiUser { flipH?: boolean; offsetX?: number; offsetY?: number; + url?: string; }[]; @Index() @@ -155,6 +156,11 @@ export class MiUser { }) public tags: string[]; + @Column('integer', { + default: 0, + }) + public score: number; + @Column('boolean', { default: false, comment: 'Whether the User is suspended.', @@ -289,5 +295,6 @@ export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toStr export const passwordSchema = { type: 'string', minLength: 1 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const; +export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const; export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 7dbe0b3717c6..554455529611 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -42,6 +42,14 @@ export class MiUserProfile { }) public description: string | null; + // フォローされた際のメッセージ + @Column('varchar', { + length: 256, nullable: true, + }) + public followedMessage: string | null; + + // TODO: 鍵アカウントの場合の、フォローリクエスト受信時のメッセージも設定できるようにする + @Column('jsonb', { default: [], }) diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts index db24c03b3dba..b4cab4edc8ec 100644 --- a/packages/backend/src/models/Webhook.ts +++ b/packages/backend/src/models/Webhook.ts @@ -8,6 +8,7 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; +export type WebhookEventTypes = typeof webhookEventTypes[number]; @Entity('webhook') export class MiWebhook { diff --git a/packages/backend/src/models/json-schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts index 952df649adad..42b2172409e9 100644 --- a/packages/backend/src/models/json-schema/flash.ts +++ b/packages/backend/src/models/json-schema/flash.ts @@ -44,6 +44,11 @@ export const packedFlashSchema = { type: 'string', optional: false, nullable: false, }, + visibility: { + type: 'string', + optional: false, nullable: false, + enum: ['private', 'public'], + }, likedCount: { type: 'number', optional: false, nullable: true, diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 3bcf9cac92d2..99feeaa7d757 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -253,6 +253,10 @@ export const packedMetaLiteSchema = { optional: false, nullable: false, default: 'local', }, + maxFileSize: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 432c096e484c..f872a27d0fda 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,6 +17,20 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: false, + format: 'date-time', + }, + updatedAtHistory: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + }, deletedAt: { type: 'string', optional: true, nullable: true, @@ -26,6 +40,14 @@ export const packedNoteSchema = { type: 'string', optional: false, nullable: true, }, + noteEditHistory: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: true, + }, + }, cw: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index b4c4442758cd..264501049101 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { notificationTypes } from '@/types.js'; +import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; +import { notificationTypes, userExportableEntities } from '@/types.js'; const baseSchema = { type: 'object', @@ -266,6 +267,10 @@ export const packedNotificationSchema = { optional: false, nullable: false, format: 'id', }, + message: { + type: 'string', + optional: false, nullable: true, + }, }, }, { type: 'object', @@ -294,6 +299,27 @@ export const packedNotificationSchema = { achievement: { type: 'string', optional: false, nullable: false, + enum: ACHIEVEMENT_TYPES, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['exportCompleted'], + }, + exportedEntity: { + type: 'string', + optional: false, nullable: false, + enum: userExportableEntities, + }, + fileId: { + type: 'string', + optional: false, nullable: false, + format: 'id', }, }, }, { @@ -311,11 +337,11 @@ export const packedNotificationSchema = { }, header: { type: 'string', - optional: false, nullable: false, + optional: false, nullable: true, }, icon: { type: 'string', - optional: false, nullable: false, + optional: false, nullable: true, }, }, }, { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7366f053560d..5e12d936512c 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -180,6 +180,38 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canEditNote: { + type: 'boolean', + optional: false, nullable: false, + }, + canInitiateConversation: { + type: 'boolean', + optional: false, nullable: false, + }, + canCreateContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canDeleteContent: { + type: 'boolean', + optional: false, nullable: false, + }, + canPurgeAccount: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateAvatar: { + type: 'boolean', + optional: false, nullable: false, + }, + canUpdateBanner: { + type: 'boolean', + optional: false, nullable: false, + }, mentionLimit: { type: 'integer', optional: false, nullable: false, @@ -272,6 +304,26 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + canImportAntennas: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportBlocking: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportFollowing: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportMuting: { + type: 'boolean', + optional: false, nullable: false, + }, + canImportUserLists: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 947a9317d7a6..16c8a5a097bb 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -370,6 +370,10 @@ export const packedUserDetailedNotMeOnlySchema = { ref: 'RoleLite', }, }, + followedMessage: { + type: 'string', + nullable: true, optional: true, + }, memo: { type: 'string', nullable: true, optional: false, @@ -437,6 +441,10 @@ export const packedMeDetailedOnlySchema = { nullable: true, optional: false, format: 'id', }, + followedMessage: { + type: 'string', + nullable: true, optional: false, + }, isModerator: { type: 'boolean', nullable: true, optional: false, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index a1fd38fcc58c..0027b5ef3d7a 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -14,6 +14,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; @@ -51,6 +52,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + BakeBufferedReactionsProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 7bd74f3210f8..e9e1c4522469 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -39,6 +39,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; @@ -118,6 +119,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -147,6 +149,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } diff --git a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts new file mode 100644 index 000000000000..d49c99f694af --- /dev/null +++ b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class BakeBufferedReactionsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.meta) + private meta: MiMeta, + + private reactionsBufferingService: ReactionsBufferingService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('bake-buffered-reactions'); + } + + @bindThis + public async process(): Promise { + if (!this.meta.enableReactionsBuffering) { + this.logger.info('Reactions buffering is disabled. Skipping...'); + return; + } + + this.logger.info('Baking buffered reactions...'); + + await this.reactionsBufferingService.bake(); + + this.logger.succ('All buffered reactions baked.'); + } +} diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index d665945861e2..9590a4fe71c5 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -7,9 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Bull from 'bullmq'; import { Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { InstancesRepository } from '@/models/_.js'; +import type { InstancesRepository, MiMeta } from '@/models/_.js'; import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; @@ -31,10 +30,12 @@ export class DeliverProcessorService { private latest: string | null; constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private metaService: MetaService, private utilityService: UtilityService, private federatedInstanceService: FederatedInstanceService, private fetchInstanceMetadataService: FetchInstanceMetadataService, @@ -45,16 +46,14 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); // 1h } @bindThis public async process(job: Bull.Job): Promise { const { host } = new URL(job.data.to); - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.toPuny(host))) { + if (!this.utilityService.isFederationAllowedUri(job.data.to)) { return 'skip (blocked)'; } @@ -88,7 +87,7 @@ export class DeliverProcessorService { this.apRequestChart.deliverSucc(); this.federationChart.deliverd(i.host, true); - if (meta.enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, true); } }); @@ -120,7 +119,7 @@ export class DeliverProcessorService { this.apRequestChart.deliverFail(); this.federationChart.deliverd(i.host, false); - if (meta.enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, false); } }); diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 88c4ea29c0e6..b3111865adc6 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -14,6 +14,7 @@ import { DriveService } from '@/core/DriveService.js'; import { bindThis } from '@/decorators.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { DBExportAntennasData } from '../types.js'; import type * as Bull from 'bullmq'; @@ -35,6 +36,7 @@ export class ExportAntennasProcessorService { private driveService: DriveService, private utilityService: UtilityService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas'); } @@ -95,6 +97,11 @@ export class ExportAntennasProcessorService { const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ('Exported to: ' + driveFile.id); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'antenna', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index 6ec3c18786fe..ecc439db69ce 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -13,6 +13,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -30,6 +31,7 @@ export class ExportBlockingProcessorService { private blockingsRepository: BlockingsRepository, private utilityService: UtilityService, + private notificationService: NotificationService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, ) { @@ -109,6 +111,11 @@ export class ExportBlockingProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'blocking', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts index 01eab26e96ab..583ddbb745a4 100644 --- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -19,6 +19,7 @@ import { bindThis } from '@/decorators.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @@ -43,6 +44,7 @@ export class ExportClipsProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-clips'); } @@ -79,6 +81,11 @@ export class ExportClipsProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'clip', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index e4eb4791bd7f..e237cd4975bc 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -16,6 +16,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp, createTempDir } from '@/misc/create-temp.js'; import { DownloadService } from '@/core/DownloadService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -37,6 +38,7 @@ export class ExportCustomEmojisProcessorService { private driveService: DriveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-custom-emojis'); } @@ -134,6 +136,12 @@ export class ExportCustomEmojisProcessorService { const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'customEmoji', + fileId: driveFile.id, + }); + cleanup(); archiveCleanup(); resolve(); diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index 7bb626dd31bf..b81feece018e 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -16,6 +16,7 @@ import type { MiPoll } from '@/models/Poll.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-favorites'); } @@ -123,6 +125,11 @@ export class ExportFavoritesProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'favorite', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 1cc80e66d718..903f96251587 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -14,6 +14,7 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { MiFollowing } from '@/models/Following.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -36,6 +37,7 @@ export class ExportFollowingProcessorService { private utilityService: UtilityService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-following'); } @@ -113,6 +115,11 @@ export class ExportFollowingProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'following', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 243b74f2c2be..f9867ade292a 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -13,6 +13,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -32,6 +33,7 @@ export class ExportMutingProcessorService { private utilityService: UtilityService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-muting'); } @@ -110,6 +112,11 @@ export class ExportMutingProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'muting', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index c7611012d74b..9e2b678219cf 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -18,6 +18,7 @@ import { bindThis } from '@/decorators.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { JsonArrayStream } from '@/misc/JsonArrayStream.js'; import { FileWriterStream } from '@/misc/FileWriterStream.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; @@ -112,6 +113,7 @@ export class ExportNotesProcessorService { private queueLoggerService: QueueLoggerService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-notes'); } @@ -150,6 +152,11 @@ export class ExportNotesProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'note', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index ee87cff5d360..c483d79854d0 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -13,6 +13,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -35,6 +36,7 @@ export class ExportUserListsProcessorService { private utilityService: UtilityService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-user-lists'); } @@ -89,6 +91,11 @@ export class ExportUserListsProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'userList', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 171809d25c3a..9e1b8fee7034 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -87,23 +87,30 @@ export class ImportCustomEmojisProcessorService { await this.emojisRepository.delete({ name: emojiInfo.name, }); - const driveFile = await this.driveService.addFile({ - user: null, - path: emojiPath, - name: record.fileName, - force: true, - }); - await this.customEmojiService.add({ - name: emojiInfo.name, - category: emojiInfo.category, - host: null, - aliases: emojiInfo.aliases, - driveFile, - license: emojiInfo.license, - isSensitive: emojiInfo.isSensitive, - localOnly: emojiInfo.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: [], - }); + try { + const driveFile = await this.driveService.addFile({ + user: null, + path: emojiPath, + name: record.fileName, + force: true, + }); + await this.customEmojiService.add({ + name: emojiInfo.name, + category: emojiInfo.category, + host: null, + aliases: emojiInfo.aliases, + driveFile, + license: emojiInfo.license, + isSensitive: emojiInfo.isSensitive, + localOnly: emojiInfo.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }); + } catch (e) { + if (e instanceof Error || typeof e === 'string') { + this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${e}`); + } + continue; + } } cleanup(); diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index fa7009f8f5d9..09d51bec7299 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -4,11 +4,10 @@ */ import { URL } from 'node:url'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; @@ -26,16 +25,28 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { MiNote } from '@/models/Note.js'; +import { MiMeta } from '@/models/Meta.js'; +import { DI } from '@/di-symbols.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { InboxJobData } from '../types.js'; +type UpdateInstanceJob = { + latestRequestReceivedAt: Date, + shouldUnsuspend: boolean, +}; + @Injectable() -export class InboxProcessorService { +export class InboxProcessorService implements OnApplicationShutdown { private logger: Logger; + private updateInstanceQueue: CollapsedQueue; constructor( + @Inject(DI.meta) + private meta: MiMeta, + private utilityService: UtilityService, - private metaService: MetaService, private apInboxService: ApInboxService, private federatedInstanceService: FederatedInstanceService, private fetchInstanceMetadataService: FetchInstanceMetadataService, @@ -48,6 +59,7 @@ export class InboxProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); + this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis @@ -63,9 +75,7 @@ export class InboxProcessorService { const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { + if (!this.utilityService.isFederationAllowedHost(host)) { return `Blocked request: ${host}`; } @@ -164,9 +174,8 @@ export class InboxProcessorService { throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); } - // ブロックしてたら中断 const ldHost = this.utilityService.extractDbHost(authUser.user.uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { + if (!this.utilityService.isFederationAllowedHost(ldHost)) { throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); } } else { @@ -185,11 +194,9 @@ export class InboxProcessorService { // Update stats this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.federatedInstanceService.update(i.id, { + this.updateInstanceQueue.enqueue(i.id, { latestRequestReceivedAt: new Date(), - isNotResponding: false, - // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる - suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined, + shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); @@ -197,7 +204,7 @@ export class InboxProcessorService { this.apRequestChart.inbox(); this.federationChart.inbox(i.host); - if (meta.enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestReceived(i.host); } }); @@ -225,4 +232,36 @@ export class InboxProcessorService { } return 'ok'; } + + @bindThis + public collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) { + const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt + ? newJob.latestRequestReceivedAt + : oldJob.latestRequestReceivedAt; + const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend; + return { + latestRequestReceivedAt, + shouldUnsuspend, + }; + } + + @bindThis + public async performUpdateInstance(id: string, job: UpdateInstanceJob) { + await this.federatedInstanceService.update(id, { + latestRequestReceivedAt: new Date(), + isNotResponding: false, + // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる + suspensionState: job.shouldUnsuspend ? 'none' : undefined, + }); + } + + @bindThis + public async dispose(): Promise { + await this.updateInstanceQueue.performAllNow(); + } + + @bindThis + async onApplicationShutdown(signal?: string) { + await this.dispose(); + } } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 77a637d89516..41b6d2e83de0 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -82,7 +82,7 @@ export class FileServerService { .catch(err => this.errorHandler(request, reply, err)); }); fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { - return await reply.redirect(301, `${this.config.url}/files/${request.params.key}`); + return await reply.redirect(`${this.config.url}/files/${request.params.key}`, 301); }); done(); }); @@ -147,12 +147,12 @@ export class FileServerService { url.searchParams.set('static', '1'); file.cleanup(); - return await reply.redirect(301, url.toString()); + return await reply.redirect(url.toString(), 301); } else if (file.mime.startsWith('video/')) { const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); if (externalThumbnail) { file.cleanup(); - return await reply.redirect(301, externalThumbnail); + return await reply.redirect(externalThumbnail, 301); } image = await this.videoProcessingService.generateVideoThumbnail(file.path); @@ -167,7 +167,7 @@ export class FileServerService { url.searchParams.set('url', file.url); file.cleanup(); - return await reply.redirect(301, url.toString()); + return await reply.redirect(url.toString(), 301); } } @@ -314,8 +314,8 @@ export class FileServerService { } return await reply.redirect( - 301, url.toString(), + 301, ); } diff --git a/packages/backend/src/server/HealthServerService.ts b/packages/backend/src/server/HealthServerService.ts index 2c3ed85925c9..5980609f02bd 100644 --- a/packages/backend/src/server/HealthServerService.ts +++ b/packages/backend/src/server/HealthServerService.ts @@ -27,6 +27,9 @@ export class HealthServerService { @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, + @Inject(DI.redisForReactions) + private redisForReactions: Redis.Redis, + @Inject(DI.db) private db: DataSource, @@ -43,6 +46,7 @@ export class HealthServerService { this.redisForPub.ping(), this.redisForSub.ping(), this.redisForTimelines.ping(), + this.redisForReactions.ping(), this.db.query('SELECT 1'), ...(this.meilisearch ? [this.meilisearch.health()] : []), ]).then(() => 200, () => 503)); diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index cc18997fdc1c..9a641007ee5b 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -134,7 +134,7 @@ export class NodeinfoServerService { return document; }; - const cache = new MemorySingleCache>>(1000 * 60 * 10); + const cache = new MemorySingleCache>>(1000 * 60 * 10); // 10m fastify.get(nodeinfo2_1path, async (request, reply) => { const base = await cache.fetch(() => nodeinfo2(21)); diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 12d50619856c..3ab0b815f232 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -46,6 +46,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ imports: [ @@ -71,6 +72,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js AuthenticateService, RateLimiterService, SigninApiService, + SigninWithPasskeyApiService, SigninService, SignupApiService, StreamingApiServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 9c849480f21f..fd2bd3267d62 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -13,7 +13,7 @@ import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; -import type { EmojisRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { EmojisRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; @@ -21,7 +21,6 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -44,6 +43,9 @@ export class ServerService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -53,7 +55,6 @@ export class ServerService implements OnApplicationShutdown { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - private metaService: MetaService, private userEntityService: UserEntityService, private apiServerService: ApiServerService, private openApiServerService: OpenApiServerService, @@ -165,8 +166,8 @@ export class ServerService implements OnApplicationShutdown { } return await reply.redirect( - 301, url.toString(), + 301, ); }); @@ -193,7 +194,7 @@ export class ServerService implements OnApplicationShutdown { reply.header('Content-Type', 'image/png'); reply.header('Cache-Control', 'public, max-age=86400'); - if ((await this.metaService.fetch()).enableIdenticonGeneration) { + if (this.meta.enableIdenticonGeneration) { return await genIdenticon(request.params.x); } else { return reply.redirect('/static-assets/avatar.png'); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 47f64f660939..aad833f1261e 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -13,8 +13,7 @@ import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; import type Logger from '@/logger.js'; -import type { UserIpsRepository } from '@/models/_.js'; -import { MetaService } from '@/core/MetaService.js'; +import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -40,13 +39,15 @@ export class ApiCallService implements OnApplicationShutdown { private userIpHistoriesClearIntervalId: NodeJS.Timeout; constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.config) private config: Config, @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - private metaService: MetaService, private authenticateService: AuthenticateService, private rateLimiterService: RateLimiterService, private roleService: RoleService, @@ -64,15 +65,6 @@ export class ApiCallService implements OnApplicationShutdown { let statusCode = err.httpStatusCode; if (err.httpStatusCode === 401) { reply.header('WWW-Authenticate', 'Bearer realm="Misskey"'); - } else if (err.kind === 'client') { - reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); - statusCode = statusCode ?? 400; - } else if (err.kind === 'permission') { - // (ROLE_PERMISSION_DENIEDは関係ない) - if (err.code === 'PERMISSION_DENIED') { - reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); - } - statusCode = statusCode ?? 403; } else if (err.code === 'RATE_LIMIT_EXCEEDED') { const info: unknown = err.info; const unixEpochInSeconds = Date.now(); @@ -83,6 +75,15 @@ export class ApiCallService implements OnApplicationShutdown { } else { this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); } + } else if (err.kind === 'client') { + reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`); + statusCode = statusCode ?? 400; + } else if (err.kind === 'permission') { + // (ROLE_PERMISSION_DENIEDは関係ない) + if (err.code === 'PERMISSION_DENIED') { + reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); + } + statusCode = statusCode ?? 403; } else if (!statusCode) { statusCode = 500; } @@ -199,9 +200,18 @@ export class ApiCallService implements OnApplicationShutdown { return; } - const [path] = await createTemp(); + const [path, cleanup] = await createTemp(); await stream.pipeline(multipartData.file, fs.createWriteStream(path)); + // ファイルサイズが制限を超えていた場合 + // なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある + if (multipartData.file.truncated) { + cleanup(); + reply.code(413); + reply.send(); + return; + } + const fields = {} as Record; for (const [k, v] of Object.entries(multipartData.fields)) { fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined; @@ -256,9 +266,8 @@ export class ApiCallService implements OnApplicationShutdown { } @bindThis - private async logIp(request: FastifyRequest, user: MiLocalUser) { - const meta = await this.metaService.fetch(); - if (!meta.enableIpLogging) return; + private logIp(request: FastifyRequest, user: MiLocalUser) { + if (!this.meta.enableIpLogging) return; const ip = request.ip; const ips = this.userIpHistories.get(user.id); if (ips == null || !ips.has(ip)) { diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 4a5935f93092..709a0446018a 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -8,6 +8,7 @@ import cors from '@fastify/cors'; import multipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; import { ModuleRef } from '@nestjs/core'; +import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { Config } from '@/config.js'; import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -17,6 +18,7 @@ import endpoints from './endpoints.js'; import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; +import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() @@ -37,6 +39,7 @@ export class ApiServerService { private apiCallService: ApiCallService, private signupApiService: SignupApiService, private signinApiService: SigninApiService, + private signinWithPasskeyApiService: SigninWithPasskeyApiService, ) { //this.createServer = this.createServer.bind(this); } @@ -49,7 +52,7 @@ export class ApiServerService { fastify.register(multipart, { limits: { - fileSize: this.config.maxFileSize ?? 262144000, + fileSize: this.config.maxFileSize, files: 1, }, }); @@ -131,6 +134,12 @@ export class ApiServerService { }; }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); + fastify.post<{ + Body: { + credential?: AuthenticationResponseJSON; + }; + }>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply)); + fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply)); fastify.get('/v1/instance/peers', async (request, reply) => { diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index ddef8db9879a..690ff2e02282 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown { private cacheService: CacheService, ) { - this.appCache = new MemoryKVCache(Infinity); + this.appCache = new MemoryKVCache(1000 * 60 * 60 * 24 * 7); // 1w } @bindThis diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 41576bedaae7..6e1eedd6b8ca 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -92,6 +92,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; +import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; @@ -258,6 +259,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; import * as ep___invite_create from './endpoints/invite/create.js'; import * as ep___invite_delete from './endpoints/invite/delete.js'; import * as ep___invite_list from './endpoints/invite/list.js'; @@ -278,6 +280,7 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; @@ -475,6 +478,7 @@ const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhoo const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; +const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; @@ -641,6 +645,7 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; +const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default }; const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default }; const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default }; const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default }; @@ -661,6 +666,7 @@ const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep__ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; +const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; @@ -862,6 +868,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_systemWebhook_list, $admin_systemWebhook_show, $admin_systemWebhook_update, + $admin_systemWebhook_test, $announcements, $announcements_show, $antennas_create, @@ -1028,6 +1035,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $i_webhooks_show, $i_webhooks_update, $i_webhooks_delete, + $i_webhooks_test, $invite_create, $invite_delete, $invite_list, @@ -1048,6 +1056,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_update, $notes_delete, $notes_favorites_create, $notes_favorites_delete, @@ -1243,6 +1252,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_systemWebhook_list, $admin_systemWebhook_show, $admin_systemWebhook_update, + $admin_systemWebhook_test, $announcements, $announcements_show, $antennas_create, @@ -1409,6 +1419,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $i_webhooks_show, $i_webhooks_update, $i_webhooks_delete, + $i_webhooks_test, $invite_create, $invite_delete, $invite_list, @@ -1429,6 +1440,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_clips, $notes_conversation, $notes_create, + $notes_update, $notes_delete, $notes_favorites_create, $notes_favorites_delete, diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts new file mode 100644 index 000000000000..9ba23c54e226 --- /dev/null +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { randomUUID } from 'crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + SigninsRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; +import { IdService } from '@/core/IdService.js'; +import { bindThis } from '@/decorators.js'; +import { WebAuthnService } from '@/core/WebAuthnService.js'; +import Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type { IdentifiableError } from '@/misc/identifiable-error.js'; +import { RateLimiterService } from './RateLimiterService.js'; +import { SigninService } from './SigninService.js'; +import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +@Injectable() +export class SigninWithPasskeyApiService { + private logger: Logger; + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private idService: IdService, + private rateLimiterService: RateLimiterService, + private signinService: SigninService, + private webAuthnService: WebAuthnService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('PasskeyAuth'); + } + + @bindThis + public async signin( + request: FastifyRequest<{ + Body: { + credential?: AuthenticationResponseJSON; + context?: string; + }; + }>, + reply: FastifyReply, + ) { + reply.header('Access-Control-Allow-Origin', this.config.url); + reply.header('Access-Control-Allow-Credentials', 'true'); + + const body = request.body; + const credential = body['credential']; + + function error(status: number, error: { id: string }) { + reply.code(status); + return { error }; + } + + const fail = async (userId: MiUser['id'], status?: number, failure?: { id: string }) => { + // Append signin history + await this.signinsRepository.insert({ + id: this.idService.gen(), + userId: userId, + ip: request.ip, + headers: request.headers as any, + success: false, + }); + return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + }; + + try { + // Not more than 1 API call per 250ms and not more than 100 attempts per 30min + // NOTE: 1 Sign-in require 2 API calls + await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); + } catch (err) { + reply.code(429); + return { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + } + + // Initiate Passkey Auth challenge with context + if (!credential) { + const context = randomUUID(); + this.logger.info(`Initiate Passkey challenge: context: ${context}`); + const authChallengeOptions = { + option: await this.webAuthnService.initiateSignInWithPasskeyAuthentication(context), + context: context, + }; + reply.code(200); + return authChallengeOptions; + } + + const context = body.context; + if (!context || typeof context !== 'string') { + // If try Authentication without context + return error(400, { + id: '1658cc2e-4495-461f-aee4-d403cdf073c1', + }); + } + + this.logger.debug(`Try Sign-in with Passkey: context: ${context}`); + + let authorizedUserId: MiUser['id'] | null; + try { + authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential); + } catch (err) { + this.logger.warn(`Passkey challenge Verify error! : ${err}`); + const errorId = (err as IdentifiableError).id; + return error(403, { + id: errorId, + }); + } + + if (!authorizedUserId) { + return error(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } + + // Fetch user + const user = await this.usersRepository.findOneBy({ + id: authorizedUserId, + host: IsNull(), + }) as MiLocalUser | null; + + if (user == null) { + return error(403, { + id: '652f899f-66d4-490e-993e-6606c8ec04c3', + }); + } + + if (user.isSuspended) { + return error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + // Authentication was successful, but passwordless login is not enabled + if (!profile.usePasswordLessLogin) { + return await fail(user.id, 403, { + id: '2d84773e-f7b7-4d0b-8f72-bb69b584c912', + }); + } + + const signinResponse = this.signinService.signin(request, reply, user); + return { + signinResponse: signinResponse, + }; + } +} diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 632b0c62bc5d..c49963801800 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -7,9 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket } from '@/models/_.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; import { IdService } from '@/core/IdService.js'; import { SignupService } from '@/core/SignupService.js'; @@ -28,6 +27,9 @@ export class SignupApiService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -45,7 +47,6 @@ export class SignupApiService { private userEntityService: UserEntityService, private idService: IdService, - private metaService: MetaService, private captchaService: CaptchaService, private signupService: SignupService, private signinService: SigninService, @@ -72,31 +73,29 @@ export class SignupApiService { ) { const body = request.body; - const instance = await this.metaService.fetch(true); - // Verify *Captcha // ただしテスト時はこの機構は障害となるため無効にする if (process.env.NODE_ENV !== 'test') { - if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { - await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } - if (instance.enableMcaptcha && instance.mcaptchaSecretKey && instance.mcaptchaSitekey && instance.mcaptchaInstanceUrl) { - await this.captchaService.verifyMcaptcha(instance.mcaptchaSecretKey, instance.mcaptchaSitekey, instance.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } - if (instance.enableRecaptcha && instance.recaptchaSecretKey) { - await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } - if (instance.enableTurnstile && instance.turnstileSecretKey) { - await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(err => { + if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { throw new FastifyReplyError(400, err); }); } @@ -108,7 +107,7 @@ export class SignupApiService { const invitationCode = body['invitationCode']; const emailAddress = body['emailAddress']; - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { if (emailAddress == null || typeof emailAddress !== 'string') { reply.code(400); return; @@ -123,7 +122,7 @@ export class SignupApiService { let ticket: MiRegistrationTicket | null = null; - if (instance.disableRegistration) { + if (this.meta.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { reply.code(400); return; @@ -144,7 +143,7 @@ export class SignupApiService { } // メアド認証が有効の場合 - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { // メアド認証済みならエラー if (ticket.usedBy) { reply.code(400); @@ -162,7 +161,7 @@ export class SignupApiService { } } - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); } @@ -172,7 +171,7 @@ export class SignupApiService { throw new FastifyReplyError(400, 'USED_USERNAME'); } - const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); if (isPreserved) { throw new FastifyReplyError(400, 'DENIED_USERNAME'); } diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3dfb7fdad4c2..b04ae824cfcb 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -98,6 +98,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; +import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; @@ -264,6 +265,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; import * as ep___invite_create from './endpoints/invite/create.js'; import * as ep___invite_delete from './endpoints/invite/delete.js'; import * as ep___invite_list from './endpoints/invite/list.js'; @@ -284,6 +286,7 @@ import * as ep___notes_children from './endpoints/notes/children.js'; import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; +import * as ep___notes_update from './endpoints/notes/update.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; @@ -479,6 +482,7 @@ const eps = [ ['admin/system-webhook/list', ep___admin_systemWebhook_list], ['admin/system-webhook/show', ep___admin_systemWebhook_show], ['admin/system-webhook/update', ep___admin_systemWebhook_update], + ['admin/system-webhook/test', ep___admin_systemWebhook_test], ['announcements', ep___announcements], ['announcements/show', ep___announcements_show], ['antennas/create', ep___antennas_create], @@ -645,6 +649,7 @@ const eps = [ ['i/webhooks/show', ep___i_webhooks_show], ['i/webhooks/update', ep___i_webhooks_update], ['i/webhooks/delete', ep___i_webhooks_delete], + ['i/webhooks/test', ep___i_webhooks_test], ['invite/create', ep___invite_create], ['invite/delete', ep___invite_delete], ['invite/list', ep___invite_list], @@ -665,6 +670,7 @@ const eps = [ ['notes/clips', ep___notes_clips], ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], + ['notes/update', ep___notes_update], ['notes/delete', ep___notes_delete], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 4074e416b8a3..01dea703a380 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; import { QueueService } from '@/core/QueueService.js'; -import { UserSuspendService } from '@/core/UserSuspendService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DeleteAccountService } from '@/core/DeleteAccountService.js'; export const meta = { tags: ['admin'], @@ -33,9 +33,7 @@ export default class extends Endpoint { // eslint- @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private userEntityService: UserEntityService, - private queueService: QueueService, - private userSuspendService: UserSuspendService, + private deleteAccoountService: DeleteAccountService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -48,22 +46,7 @@ export default class extends Endpoint { // eslint- throw new Error('cannot delete a root account'); } - if (this.userEntityService.isLocalUser(user)) { - // 物理削除する前にDelete activityを送信する - await this.userSuspendService.doPostSuspend(user).catch(err => {}); - - this.queueService.createDeleteAccountJob(user, { - soft: false, - }); - } else { - this.queueService.createDeleteAccountJob(user, { - soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する - }); - } - - await this.usersRepository.update(user.id, { - isDeleted: true, - }); + await this.deleteAccoountService.deleteAccount(user); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2e7f73da73b2..b76ed5c5242f 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -377,6 +377,10 @@ export const meta = { type: 'number', optional: false, nullable: false, }, + enableReactionsBuffering: { + type: 'boolean', + optional: false, nullable: false, + }, notesPerOneAd: { type: 'number', optional: false, nullable: false, @@ -491,6 +495,18 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + federation: { + type: 'string', + optional: false, nullable: false, + }, + federationHosts: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, }, }, } as const; @@ -617,6 +633,7 @@ export default class extends Endpoint { // eslint- perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax, perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, + enableReactionsBuffering: instance.enableReactionsBuffering, notesPerOneAd: instance.notesPerOneAd, summalyProxy: instance.urlPreviewSummaryProxyUrl, urlPreviewEnabled: instance.urlPreviewEnabled, @@ -625,6 +642,8 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + federation: instance.federation, + federationHosts: instance.federationHosts, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts index 7a3410ffa751..f3e440b4cb51 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -21,16 +21,15 @@ export const meta = { items: { type: 'array', optional: false, nullable: false, - items: { - anyOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - ], - }, + prefixItems: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + unevaluatedItems: false, }, example: [[ 'example.com', diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index 305ae1af1da5..e7589cba8112 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -21,16 +21,15 @@ export const meta = { items: { type: 'array', optional: false, nullable: false, - items: { - anyOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - ], - }, + prefixItems: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + unevaluatedItems: false, }, example: [[ 'example.com', diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts index d7209965dba1..5cf49670be5a 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MetaService } from '@/core/MetaService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin', 'role'], @@ -33,12 +34,22 @@ export default class extends Endpoint { // eslint- constructor( private metaService: MetaService, private globalEventService: GlobalEventService, + private moderationLogService: ModerationLogService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { + const before = await this.metaService.fetch(true); + await this.metaService.update({ policies: ps.policies, }); - this.globalEventService.publishInternalEvent('policiesUpdated', ps.policies); + + const after = await this.metaService.fetch(true); + + this.globalEventService.publishInternalEvent('policiesUpdated', after.policies); + this.moderationLogService.log(me, 'updateServerSettings', { + before: before.policies, + after: after.policies, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 5a1c05f41aa6..655bd32bce06 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -31,6 +31,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + followedMessage: { + type: 'string', + optional: false, nullable: true, + }, autoAcceptFollowed: { type: 'boolean', optional: false, nullable: false, @@ -226,6 +230,7 @@ export default class extends Endpoint { // eslint- return { email: profile.email, emailVerified: profile.emailVerified, + followedMessage: profile.followedMessage, autoAcceptFollowed: profile.autoAcceptFollowed, noCrawle: profile.noCrawle, preventAiLearning: profile.preventAiLearning, diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 8a946405cc0c..bea1bdc4edb8 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -3,18 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { IsNull, Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import type { RelationshipJobData } from '@/queue/types.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import type { UsersRepository } from '@/models/_.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -38,13 +32,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - private userSuspendService: UserSuspendService, private roleService: RoleService, - private moderationLogService: ModerationLogService, - private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -57,42 +46,7 @@ export default class extends Endpoint { // eslint- throw new Error('cannot suspend moderator account'); } - await this.usersRepository.update(user.id, { - isSuspended: true, - }); - - this.moderationLogService.log(me, 'suspend', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - }); - - (async () => { - await this.userSuspendService.doPostSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); - })(); - }); - } - - @bindThis - private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { - followerId: follower.id, - followeeId: Not(IsNull()), - }, + await this.userSuspendService.suspend(user, me); }); - - const jobs: RelationshipJobData[] = []; - for (const following of followings) { - if (following.followeeId && following.followerId) { - jobs.push({ - from: { id: following.followerId }, - to: { id: following.followeeId }, - silent: true, - }); - } - } - this.queueService.createUnfollowJob(jobs); } } diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts new file mode 100644 index 000000000000..fb2ddf4b446d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { WebhookTestService } from '@/core/WebhookTestService.js'; +import { ApiError } from '@/server/api/error.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; + +export const meta = { + tags: ['webhooks'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'read:admin:system-webhook', + + limit: { + duration: ms('15min'), + max: 60, + }, + + errors: { + noSuchWebhook: { + message: 'No such webhook.', + code: 'NO_SUCH_WEBHOOK', + id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + webhookId: { + type: 'string', + format: 'misskey:id', + }, + type: { + type: 'string', + enum: systemWebhookEventTypes, + }, + override: { + type: 'object', + properties: { + url: { type: 'string', nullable: false }, + secret: { type: 'string', nullable: false }, + }, + }, + }, + required: ['webhookId', 'type'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private webhookTestService: WebhookTestService, + ) { + super(meta, paramDef, async (ps) => { + try { + await this.webhookTestService.testSystemWebhook({ + webhookId: ps.webhookId, + type: ps.type, + override: ps.override, + }); + } catch (e) { + if (e instanceof WebhookTestService.NoSuchWebhookError) { + throw new ApiError(meta.errors.noSuchWebhook); + } + throw e; + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts index 2c2b1bf6f55a..b52c638cdb6f 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -6,7 +6,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; import { DI } from '@/di-symbols.js'; @@ -33,7 +32,6 @@ export default class extends Endpoint { // eslint- private usersRepository: UsersRepository, private userSuspendService: UserSuspendService, - private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -42,17 +40,7 @@ export default class extends Endpoint { // eslint- throw new Error('user not found'); } - await this.usersRepository.update(user.id, { - isSuspended: false, - }); - - this.moderationLogService.log(me, 'unsuspend', { - userId: user.id, - userUsername: user.username, - userHost: user.host, - }); - - this.userSuspendService.doPostUnsuspend(user); + await this.userSuspendService.unsuspend(user, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 5efdc9d8c457..daef236397da 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -142,6 +142,7 @@ export const paramDef = { perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, perUserListTimelineCacheMax: { type: 'integer' }, + enableReactionsBuffering: { type: 'boolean' }, notesPerOneAd: { type: 'integer' }, silencedHosts: { type: 'array', @@ -167,6 +168,16 @@ export const paramDef = { urlPreviewRequireContentLength: { type: 'boolean' }, urlPreviewUserAgent: { type: 'string', nullable: true }, urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, + federation: { + type: 'string', + enum: ['all', 'none', 'specified'], + }, + federationHosts: { + type: 'array', + items: { + type: 'string', + }, + }, }, required: [], } as const; @@ -598,6 +609,10 @@ export default class extends Endpoint { // eslint- set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax; } + if (ps.enableReactionsBuffering !== undefined) { + set.enableReactionsBuffering = ps.enableReactionsBuffering; + } + if (ps.notesPerOneAd !== undefined) { set.notesPerOneAd = ps.notesPerOneAd; } @@ -632,6 +647,14 @@ export default class extends Endpoint { // eslint- set.urlPreviewSummaryProxyUrl = value === '' ? null : value; } + if (ps.federation !== undefined) { + set.federation = ps.federation; + } + + if (Array.isArray(ps.federationHosts)) { + set.blockedHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 577b9e1b1f8c..e0c8ddcc8478 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -34,6 +34,12 @@ export const meta = { code: 'TOO_MANY_ANTENNAS', id: 'faf47050-e8b5-438c-913c-db2b1576fde4', }, + + emptyKeyword: { + message: 'Either keywords or excludeKeywords is required.', + code: 'EMPTY_KEYWORD', + id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', + }, }, res: { @@ -87,7 +93,7 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { - throw new Error('either keywords or excludeKeywords is required.'); + throw new ApiError(meta.errors.emptyKeyword); } const currentAntennasCount = await this.antennasRepository.countBy({ diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 0c30bca9e0bf..10f26b19126e 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -32,6 +32,12 @@ export const meta = { code: 'NO_SUCH_USER_LIST', id: '1c6b35c9-943e-48c2-81e4-2844989407f7', }, + + emptyKeyword: { + message: 'Either keywords or excludeKeywords is required.', + code: 'EMPTY_KEYWORD', + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', + }, }, res: { @@ -85,7 +91,7 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { if (ps.keywords && ps.excludeKeywords) { if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { - throw new Error('either keywords or excludeKeywords is required.'); + throw new ApiError(meta.errors.emptyKeyword); } } // Fetch the antenna diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index d3c40dba590e..c4d8d2a45a8f 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; @@ -12,7 +12,6 @@ import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { MetaService } from '@/core/MetaService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -20,6 +19,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '../../error.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['federation'], @@ -88,10 +89,12 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private utilityService: UtilityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, - private metaService: MetaService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, @@ -112,9 +115,7 @@ export default class extends Endpoint { // eslint- */ @bindThis private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { - // ブロックしてたら中断 - const fetchedMeta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) return null; + if (!this.utilityService.isFederationAllowedUri(uri)) return null; let local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 8c5567359088..d4fd75e04955 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -5,14 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/_.js'; +import type { ChannelsRepository, MiMeta, NotesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; @@ -58,6 +56,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -68,16 +69,12 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private cacheService: CacheService, private activeUsersChart: ActiveUsersChart, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const serverSettings = await this.metaService.fetch(); - const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, }); @@ -88,7 +85,7 @@ export default class extends Endpoint { // eslint- if (me) this.activeUsersChart.read(me); - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); } diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index 7e9b0fa0e1e4..eb45e29f9e12 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { RoleService } from '@/core/RoleService.js'; @@ -41,14 +40,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private metaService: MetaService, private driveFileEntityService: DriveFileEntityService, private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const instance = await this.metaService.fetch(true); - - // Calculate drive usage const usage = await this.driveFileEntityService.calcDriveUsageOf(me.id); const policies = await this.roleService.getUserPolicies(me.id); diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index 467039202524..b86059b5e7ac 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -10,6 +10,7 @@ import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['drive', 'notes'], @@ -61,12 +62,13 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { // Fetch file const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId, - userId: me.id, + userId: await this.roleService.isModerator(me) ? undefined : me.id, }); if (file == null) { diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 9c17f93ab26b..74eb4dded7ce 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -4,14 +4,15 @@ */ import ms from 'ms'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { DriveService } from '@/core/DriveService.js'; import { ApiError } from '../../../error.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -73,8 +74,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private driveFileEntityService: DriveFileEntityService, - private metaService: MetaService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { @@ -91,8 +94,6 @@ export default class extends Endpoint { // eslint- } } - const instance = await this.metaService.fetch(); - try { // Create file const driveFile = await this.driveService.addFile({ @@ -103,8 +104,8 @@ export default class extends Endpoint { // eslint- folderId: ps.folderId, force: ps.force, sensitive: ps.isSensitive, - requestIp: instance.enableIpLogging ? ip : null, - requestHeaders: instance.enableIpLogging ? headers : null, + requestIp: this.serverSettings.enableIpLogging ? ip : null, + requestHeaders: this.serverSettings.enableIpLogging ? headers : null, }); return await this.driveFileEntityService.pack(driveFile, { self: true }); } catch (err) { diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts index fe7e9c36f3ad..7fc7644ed9e1 100644 --- a/packages/backend/src/server/api/endpoints/endpoint.ts +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import endpoints from '../endpoints.js'; +import endpoints, { IEndpoint } from '../endpoints.js'; export const meta = { requireCredential: false, @@ -42,8 +42,8 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( ) { - super(meta, paramDef, async (ps) => { - const ep = endpoints.find(x => x.name === ps.endpoint); + super(meta, paramDef, async (ps: {endpoint?: string}) => { + const ep = endpoints.find((x: IEndpoint) => x.name === ps.endpoint); if (ep == null) return null; return { params: Object.entries(ep.params.properties ?? {}).map(([k, v]) => ({ diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 36f4bf5aa682..41954129e636 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -170,7 +170,7 @@ export default class extends Endpoint { // eslint- const instances = await query.limit(ps.limit).offset(ps.offset).getMany(); - return await this.instanceEntityService.packMany(instances); + return await this.instanceEntityService.packMany(instances, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts index bac54970ab85..69900bff9aca 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -107,9 +107,9 @@ export default class extends Endpoint { // eslint- const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); return await awaitAll({ - topSubInstances: this.instanceEntityService.packMany(topSubInstances), + topSubInstances: this.instanceEntityService.packMany(topSubInstances, me), otherFollowersCount: Math.max(0, allSubCount - gotSubCount), - topPubInstances: this.instanceEntityService.packMany(topPubInstances), + topPubInstances: this.instanceEntityService.packMany(topPubInstances, me), otherFollowingCount: Math.max(0, allPubCount - gotPubCount), }); }); diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts index d3d47e5deb93..6912450abf7e 100644 --- a/packages/backend/src/server/api/endpoints/flash/delete.ts +++ b/packages/backend/src/server/api/endpoints/flash/delete.ts @@ -4,9 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; +import type { FlashsRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,17 +46,35 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private moderationLogService: ModerationLogService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + if (flash == null) { throw new ApiError(meta.errors.noSuchFlash); } - if (flash.userId !== me.id) { + + if (!await this.roleService.isModerator(me) && flash.userId !== me.id) { throw new ApiError(meta.errors.accessDenied); } await this.flashsRepository.delete(flash.id); + + if (flash.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: flash.userId }); + this.moderationLogService.log(me, 'deleteFlash', { + flashId: flash.id, + flashUserId: flash.userId, + flashUserUsername: user.username, + flash, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index 527e3fb52ddc..b6b94db1613e 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -5,8 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; +import type { GalleryPostsRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -22,6 +24,12 @@ export const meta = { code: 'NO_SUCH_POST', id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5', }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'c86e09de-1c48-43ac-a435-1c7e42ed4496', + }, }, } as const; @@ -38,18 +46,35 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.galleryPostsRepository) private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private moderationLogService: ModerationLogService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const post = await this.galleryPostsRepository.findOneBy({ - id: ps.postId, - userId: me.id, - }); + const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); if (post == null) { throw new ApiError(meta.errors.noSuchPost); } + if (!await this.roleService.isModerator(me) && post.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + await this.galleryPostsRepository.delete(post.id); + + if (post.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: post.userId }); + this.moderationLogService.log(me, 'deleteGalleryPost', { + postId: post.id, + postUserId: post.userId, + postUserUsername: user.username, + post, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index bc46163e3d27..bdf6c065e89c 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -16,6 +16,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportAntennas', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 260610853930..d7bb6bcd2256 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportBlocking', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index d5e824df2740..e03192d8c67b 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportFollowing', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 0f5800404eaf..76b285bb7e3f 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportMuting', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index bacdd5c88f28..76ecfd082ca3 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -15,6 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + requireRolePolicy: 'canImportUserLists', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index eea657ebbd07..da1faee30d15 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import bcrypt from 'bcryptjs'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; +import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { EmailService } from '@/core/EmailService.js'; import type { Config } from '@/config.js'; @@ -15,7 +15,6 @@ import { DI } from '@/di-symbols.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import { MetaService } from '@/core/MetaService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -70,10 +69,12 @@ export default class extends Endpoint { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private metaService: MetaService, private userEntityService: UserEntityService, private emailService: EmailService, private userAuthService: UserAuthService, @@ -105,7 +106,7 @@ export default class extends Endpoint { // eslint- if (!res.available) { throw new ApiError(meta.errors.unavailable); } - } else if ((await this.metaService.fetch()).emailRequiredForSignup) { + } else if (this.serverSettings.emailRequiredForSignup) { throw new ApiError(meta.errors.emailRequired); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index a1e2fa5e4cdf..798bd98cf1f4 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -13,9 +13,8 @@ import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js'; +import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; -import { notificationTypes } from '@/types.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { langmap } from '@/misc/langmap.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -134,6 +133,7 @@ export const paramDef = { properties: { name: { ...nameSchema, nullable: true }, description: { ...descriptionSchema, nullable: true }, + followedMessage: { ...followedMessageSchema, nullable: true }, location: { ...locationSchema, nullable: true }, birthday: { ...birthdaySchema, nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, @@ -267,6 +267,7 @@ export default class extends Endpoint { // eslint- } } if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.followedMessage !== undefined) profileUpdates.followedMessage = ps.followedMessage; if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index 9eb7f5b3a033..6e84603f7a45 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '@/server/api/error.js'; +// TODO: UserWebhook schemaの適用 export const meta = { tags: ['webhooks'], diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts index fe07afb2d089..394c178f2adf 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts @@ -9,6 +9,7 @@ import { webhookEventTypes } from '@/models/Webhook.js'; import type { WebhooksRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +// TODO: UserWebhook schemaの適用 export const meta = { tags: ['webhooks', 'account'], diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts index 5ddb79caf283..4a0c09ff0ccb 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts @@ -10,6 +10,7 @@ import type { WebhooksRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; +// TODO: UserWebhook schemaの適用 export const meta = { tags: ['webhooks'], diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/test.ts b/packages/backend/src/server/api/endpoints/i/webhooks/test.ts new file mode 100644 index 000000000000..2bf6df9ce2f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/test.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { webhookEventTypes } from '@/models/Webhook.js'; +import { WebhookTestService } from '@/core/WebhookTestService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['webhooks'], + + requireCredential: true, + secure: true, + kind: 'read:account', + + limit: { + duration: ms('15min'), + max: 60, + }, + + errors: { + noSuchWebhook: { + message: 'No such webhook.', + code: 'NO_SUCH_WEBHOOK', + id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + webhookId: { + type: 'string', + format: 'misskey:id', + }, + type: { + type: 'string', + enum: webhookEventTypes, + }, + override: { + type: 'object', + properties: { + url: { type: 'string' }, + secret: { type: 'string' }, + }, + }, + }, + required: ['webhookId', 'type'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private webhookTestService: WebhookTestService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + await this.webhookTestService.testUserWebhook({ + webhookId: ps.webhookId, + type: ps.type, + override: ps.override, + }, me); + } catch (e) { + if (e instanceof WebhookTestService.NoSuchWebhookError) { + throw new ApiError(meta.errors.noSuchWebhook); + } + throw e; + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index beb77ca7ab02..253a36081514 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,8 +17,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; -import { MetaService } from '@/core/MetaService.js'; -import { UtilityService } from '@/core/UtilityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2a2c6599427d..aed9065bf953 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -16,7 +16,6 @@ import { CacheService } from '@/core/CacheService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; @@ -74,6 +73,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -87,7 +89,6 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, - private metaService: MetaService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { super(meta, paramDef, async (ps, me) => { @@ -101,9 +102,7 @@ export default class extends Endpoint { // eslint- if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -156,7 +155,7 @@ export default class extends Endpoint { // eslint- allowPartial: ps.allowPartial, me, redisTimelines: timelineConfig, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, noteFilter: note => { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index be82b5a8a749..0b48f2c78bd4 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -5,16 +5,14 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiMeta, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; @@ -66,6 +64,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -73,10 +74,8 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -89,9 +88,7 @@ export default class extends Endpoint { // eslint- if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -115,7 +112,7 @@ export default class extends Endpoint { // eslint- limit: ps.limit, allowPartial: ps.allowPartial, me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, redisTimelines: ps.withFiles ? ['localTimelineWithFiles'] : ps.withReplies ? ['localTimeline', 'localTimelineWithReplies'] diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index c9b43b535937..7cb11cc1ebfc 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -15,7 +15,6 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; export const meta = { @@ -56,6 +55,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -69,15 +71,12 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, private queryService: QueryService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -108,7 +107,7 @@ export default class extends Endpoint { // eslint- limit: ps.limit, allowPartial: ps.allowPartial, me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 38a9660aa239..e9a6a36b0266 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -4,14 +4,15 @@ */ import { URLSearchParams } from 'node:url'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -59,9 +60,11 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private noteEntityService: NoteEntityService, private getterService: GetterService, - private metaService: MetaService, private httpRequestService: HttpRequestService, private roleService: RoleService, ) { @@ -84,9 +87,7 @@ export default class extends Endpoint { // eslint- return; } - const instance = await this.metaService.fetch(); - - if (instance.deeplAuthKey == null) { + if (this.serverSettings.deeplAuthKey == null) { throw new ApiError(meta.errors.unavailable); } @@ -94,11 +95,11 @@ export default class extends Endpoint { // eslint- if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; const params = new URLSearchParams(); - params.append('auth_key', instance.deeplAuthKey); + params.append('auth_key', this.serverSettings.deeplAuthKey); params.append('text', note.text); params.append('target_lang', targetLang); - const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + const endpoint = this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; const res = await this.httpRequestService.send(endpoint, { method: 'POST', diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts new file mode 100644 index 000000000000..ef84220ef2a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canEditNote', + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec'), + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474', + }, + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + text: { + type: 'string', + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: true, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + mediaIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + cw: { type: 'string', nullable: true, maxLength: 100 }, + disableRightClick: { type: 'boolean', default: false }, + }, + required: ['noteId', 'text', 'cw'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private noteEntityService: NoteEntityService, + private noteUpdateService: NoteUpdateService, + ) { + super({ + ...meta, + requireRolePolicy: 'canEditNote', + }, paramDef, async (ps, me) => { + const note = await this.getterService.getNote(ps.noteId).catch(err => { + if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.userId !== me.id) { + throw new ApiError(meta.errors.noSuchNote); + } + + let files: MiDriveFile[] = []; + const fileIds = ps.fileIds ?? ps.mediaIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + const data = { + text: ps.text, + files: files, + cw: ps.cw, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : undefined, + }; + + const updatedNote = await this.noteUpdateService.update(me, data, note, false); + + return { + updatedNote: await this.noteEntityService.pack(updatedNote!, me), + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 43877e61efaf..6c7185c9ebba 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -5,16 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; -import type { MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiMeta, MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; -import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; @@ -69,6 +67,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -80,11 +81,9 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -99,9 +98,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchList); } - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb(list, { untilId, sinceId, @@ -124,7 +121,7 @@ export default class extends Endpoint { // eslint- limit: ps.limit, allowPartial: ps.allowPartial, me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index aa2ba75a4162..f2bc946788fd 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -4,9 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository } from '@/models/_.js'; +import type { PagesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,17 +46,35 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private moderationLogService: ModerationLogService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { throw new ApiError(meta.errors.noSuchPage); } - if (page.userId !== me.id) { + + if (!await this.roleService.isModerator(me) && page.userId !== me.id) { throw new ApiError(meta.errors.accessDenied); } await this.pagesRepository.delete(page.id); + + if (page.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: page.userId }); + this.moderationLogService.log(me, 'deletePage', { + pageId: page.id, + pageUserId: page.userId, + pageUserUsername: user.username, + page, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 15832ef7f8d7..5b0b656c63fe 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -5,11 +5,10 @@ import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import * as Acct from '@/misc/acct.js'; import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -38,16 +37,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private metaService: MetaService, private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const meta = await this.metaService.fetch(); - - const users = await Promise.all(meta.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => this.usersRepository.findOneBy({ + const users = await Promise.all(this.serverSettings.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => this.usersRepository.findOneBy({ usernameLower: acct.username.toLowerCase(), host: acct.host ?? IsNull(), }))); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index c13802eb0689..8301c85f2eb6 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -5,9 +5,10 @@ import * as os from 'node:os'; import si from 'systeminformation'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: false, @@ -73,10 +74,11 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private metaService: MetaService, + @Inject(DI.meta) + private serverSettings: MiMeta, ) { super(meta, paramDef, async () => { - if (!(await this.metaService.fetch()).enableServerMachineStats) return { + if (!this.serverSettings.enableServerMachineStats) return { machine: '?', cpu: { model: '?', diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index a9a33149f95a..fd76df2d3c0b 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -5,9 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IdService } from '@/core/IdService.js'; -import type { SwSubscriptionsRepository } from '@/models/_.js'; +import type { MiMeta, SwSubscriptionsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { DI } from '@/di-symbols.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; @@ -62,11 +61,13 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, private idService: IdService, - private metaService: MetaService, private pushNotificationService: PushNotificationService, ) { super(meta, paramDef, async (ps, me) => { @@ -78,12 +79,10 @@ export default class extends Endpoint { // eslint- publickey: ps.publickey, }); - const instance = await this.metaService.fetch(true); - if (exist != null) { return { state: 'already-subscribed' as const, - key: instance.swPublicKey, + key: this.serverSettings.swPublicKey, userId: me.id, endpoint: exist.endpoint, sendReadMessage: exist.sendReadMessage, @@ -103,7 +102,7 @@ export default class extends Endpoint { // eslint- return { state: 'subscribed' as const, - key: instance.swPublicKey, + key: this.serverSettings.swPublicKey, userId: me.id, endpoint: ps.endpoint, sendReadMessage: ps.sendReadMessage, diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index affb0996f182..4944be9b0541 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -5,11 +5,10 @@ import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { localUsernameSchema } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['users'], @@ -39,13 +38,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.usedUsernamesRepository) private usedUsernamesRepository: UsedUsernamesRepository, - - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const exist = await this.usersRepository.countBy({ @@ -55,8 +55,7 @@ export default class extends Endpoint { // eslint- const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); - const meta = await this.metaService.fetch(); - const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); + const isPreserved = this.serverSettings.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); return { available: exist === 0 && exist2 === 0 && !isPreserved, diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 7ce7734f53ff..a8b4319a61c1 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -81,6 +82,7 @@ export default class extends Endpoint { // eslint- private utilityService: UtilityService, private followingEntityService: FollowingEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy(ps.userId != null @@ -93,22 +95,24 @@ export default class extends Endpoint { // eslint- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.followersVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.followersVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); - if (!isFollowing) { + if (profile.followersVisibility !== 'public' && !await this.roleService.isModerator(me)) { + if (profile.followersVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.followersVisibility === 'followers') { + if (me == null) { throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowing = await this.followingsRepository.exists({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowing) { + throw new ApiError(meta.errors.forbidden); + } } } } diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 6b3389f0b242..feda5bb353b3 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -12,6 +12,7 @@ import { QueryService } from '@/core/QueryService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -90,6 +91,7 @@ export default class extends Endpoint { // eslint- private utilityService: UtilityService, private followingEntityService: FollowingEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy(ps.userId != null @@ -102,22 +104,24 @@ export default class extends Endpoint { // eslint- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.followingVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.followingVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); - if (!isFollowing) { + if (profile.followingVisibility !== 'public' && !await this.roleService.isModerator(me)) { + if (profile.followingVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.followingVisibility === 'followers') { + if (me == null) { throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowing = await this.followingsRepository.exists({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowing) { + throw new ApiError(meta.errors.forbidden); + } } } } diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index cc76c12f1d89..7fc11ba36905 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,14 +5,13 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiMeta, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; @@ -67,6 +66,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -75,15 +77,12 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); const isSelf = me && (me.id === ps.userId); - const serverSettings = await this.metaService.fetch(); - if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); // early return if me is blocked by requesting user @@ -94,7 +93,7 @@ export default class extends Endpoint { // eslint- } } - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 96082827f81f..0fb5238c78e5 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -14,11 +14,14 @@ import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import type { JsonObject } from '@/misc/json-value.js'; +import { isJsonObject } from '@/misc/json-value.js'; +import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; +const MAX_CHANNELS_PER_CONNECTION = 32; + /** * Main stream connection */ @@ -112,8 +115,6 @@ export default class Connection { const { type, body } = obj; - if (typeof body !== 'object' || body === null || Array.isArray(body)) return; - switch (type) { case 'readNotification': this.onReadNotification(body); break; case 'subNote': this.onSubscribeNote(body); break; @@ -154,7 +155,8 @@ export default class Connection { } @bindThis - private readNote(body: JsonObject) { + private readNote(body: JsonValue | undefined) { + if (!isJsonObject(body)) return; const id = body.id; const note = this.cachedNotes.find(n => n.id === id); @@ -166,7 +168,7 @@ export default class Connection { } @bindThis - private onReadNotification(payload: JsonObject) { + private onReadNotification(payload: JsonValue | undefined) { this.notificationService.readAllNotification(this.user!.id); } @@ -174,7 +176,8 @@ export default class Connection { * 投稿購読要求時 */ @bindThis - private onSubscribeNote(payload: JsonObject) { + private onSubscribeNote(payload: JsonValue | undefined) { + if (!isJsonObject(payload)) return; if (!payload.id || typeof payload.id !== 'string') return; const current = this.subscribingNotes[payload.id] ?? 0; @@ -190,7 +193,8 @@ export default class Connection { * 投稿購読解除要求時 */ @bindThis - private onUnsubscribeNote(payload: JsonObject) { + private onUnsubscribeNote(payload: JsonValue | undefined) { + if (!isJsonObject(payload)) return; if (!payload.id || typeof payload.id !== 'string') return; const current = this.subscribingNotes[payload.id]; @@ -216,12 +220,13 @@ export default class Connection { * チャンネル接続要求時 */ @bindThis - private onChannelConnectRequested(payload: JsonObject) { + private onChannelConnectRequested(payload: JsonValue | undefined) { + if (!isJsonObject(payload)) return; const { channel, id, params, pong } = payload; if (typeof id !== 'string') return; if (typeof channel !== 'string') return; if (typeof pong !== 'boolean' && typeof pong !== 'undefined' && pong !== null) return; - if (typeof params !== 'undefined' && (typeof params !== 'object' || params === null || Array.isArray(params))) return; + if (typeof params !== 'undefined' && !isJsonObject(params)) return; this.connectChannel(id, params, channel, pong ?? undefined); } @@ -229,7 +234,8 @@ export default class Connection { * チャンネル切断要求時 */ @bindThis - private onChannelDisconnectRequested(payload: JsonObject) { + private onChannelDisconnectRequested(payload: JsonValue | undefined) { + if (!isJsonObject(payload)) return; const { id } = payload; if (typeof id !== 'string') return; this.disconnectChannel(id); @@ -251,6 +257,10 @@ export default class Connection { */ @bindThis public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { + if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) { + return; + } + const channelService = this.channelsService.getChannelService(channel); if (channelService.requireCredential && this.user == null) { @@ -297,7 +307,8 @@ export default class Connection { * @param data メッセージ */ @bindThis - private onChannelMessageRequested(data: JsonObject) { + private onChannelMessageRequested(data: JsonValue | undefined) { + if (!isJsonObject(data)) return; if (typeof data.id !== 'string') return; if (typeof data.type !== 'string') return; if (typeof data.body === 'undefined') return; diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index ff7e7402261b..91b62255b4f6 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -6,6 +6,7 @@ import Xev from 'xev'; import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; @@ -36,7 +37,7 @@ class QueueStatsChannel extends Channel { public onMessage(type: string, body: JsonValue) { switch (type) { case 'requestLog': - if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + if (!isJsonObject(body)) return; if (typeof body.id !== 'string') return; if (typeof body.length !== 'number') return; ev.once(`queueStatsLog:${body.id}`, statsLog => { diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 17823a164a2d..7597a1cfa332 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -9,8 +9,10 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { ReversiService } from '@/core/ReversiService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; +import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; +import { reversiUpdateKeys } from 'misskey-js'; class ReversiGameChannel extends Channel { public readonly chName = 'reversiGame'; @@ -44,16 +46,17 @@ class ReversiGameChannel extends Channel { this.ready(body); break; case 'updateSettings': - if (typeof body !== 'object' || body === null || Array.isArray(body)) return; - if (typeof body.key !== 'string') return; - if (typeof body.value !== 'object' || body.value === null || Array.isArray(body.value)) return; + if (!isJsonObject(body)) return; + if (!this.reversiService.isValidReversiUpdateKey(body.key)) return; + if (!this.reversiService.isValidReversiUpdateValue(body.key, body.value)) return; + this.updateSettings(body.key, body.value); break; case 'cancel': this.cancelGame(); break; case 'putStone': - if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + if (!isJsonObject(body)) return; if (typeof body.pos !== 'number') return; if (typeof body.id !== 'string') return; this.putStone(body.pos, body.id); @@ -63,7 +66,7 @@ class ReversiGameChannel extends Channel { } @bindThis - private async updateSettings(key: string, value: JsonObject) { + private async updateSettings(key: K, value: MiReversiGame[K]) { if (this.user == null) return; this.reversiService.updateSettings(this.gameId!, this.user, key, value); diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index 6258afba35c3..ec5352d12dba 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -6,6 +6,7 @@ import Xev from 'xev'; import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; @@ -36,7 +37,7 @@ class ServerStatsChannel extends Channel { public onMessage(type: string, body: JsonValue) { switch (type) { case 'requestLog': - if (typeof body !== 'object' || body === null || Array.isArray(body)) return; + if (!isJsonObject(body)) return; ev.once(`serverStatsLog:${body.id}`, statsLog => { this.send('statsLog', statsLog); }); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index f55790b6363b..dd7bb7823efe 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import { createBullBoard } from '@bull-board/api'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js'; -import { FastifyAdapter } from '@bull-board/fastify'; +import { FastifyAdapter as BullBoardFastifyAdapter } from '@bull-board/fastify'; import ms from 'ms'; import sharp from 'sharp'; import pug from 'pug'; @@ -24,7 +24,6 @@ import type { Config } from '@/config.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; import * as Acct from '@/misc/acct.js'; -import { MetaService } from '@/core/MetaService.js'; import type { DbQueue, DeliverQueue, @@ -61,7 +60,8 @@ const staticAssets = `${_dirname}/../../../assets/`; const clientAssets = `${_dirname}/../../../../frontend/assets/`; const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; -const viteOut = `${_dirname}/../../../../../built/_vite_/`; +const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; +const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; const tarball = `${_dirname}/../../../../../built/tarball/`; @Injectable() @@ -72,6 +72,9 @@ export class ClientServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -108,7 +111,6 @@ export class ClientServerService { private clipEntityService: ClipEntityService, private channelEntityService: ChannelEntityService, private reversiGameEntityService: ReversiGameEntityService, - private metaService: MetaService, private urlPreviewService: UrlPreviewService, private feedService: FeedService, private roleService: RoleService, @@ -128,32 +130,30 @@ export class ClientServerService { @bindThis private async manifestHandler(reply: FastifyReply) { - const instance = await this.metaService.fetch(true); - let manifest = { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'short_name': instance.shortName || instance.name || this.config.host, + 'short_name': this.meta.shortName || this.meta.name || this.config.host, // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'name': instance.name || this.config.host, + 'name': this.meta.name || this.config.host, 'start_url': '/', 'display': 'standalone', 'background_color': '#313a42', // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'theme_color': instance.themeColor || '#86b300', + 'theme_color': this.meta.themeColor || '#86b300', 'icons': [{ // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app192IconUrl || '/static-assets/icons/192.png', + 'src': this.meta.app192IconUrl || '/static-assets/icons/192.png', 'sizes': '192x192', 'type': 'image/png', 'purpose': 'maskable', }, { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app512IconUrl || '/static-assets/icons/512.png', + 'src': this.meta.app512IconUrl || '/static-assets/icons/512.png', 'sizes': '512x512', 'type': 'image/png', 'purpose': 'maskable', @@ -177,7 +177,7 @@ export class ClientServerService { manifest = { ...manifest, - ...JSON.parse(instance.manifestJsonOverride === '' ? '{}' : instance.manifestJsonOverride), + ...JSON.parse(this.meta.manifestJsonOverride === '' ? '{}' : this.meta.manifestJsonOverride), }; reply.header('Cache-Control', 'max-age=300'); @@ -239,7 +239,7 @@ export class ClientServerService { } }); - const serverAdapter = new FastifyAdapter(); + const bullBoardServerAdapter = new BullBoardFastifyAdapter(); createBullBoard({ queues: [ @@ -252,11 +252,11 @@ export class ClientServerService { this.userWebhookDeliverQueue, this.systemWebhookDeliverQueue, ].map(q => new BullMQAdapter(q)), - serverAdapter, + serverAdapter: bullBoardServerAdapter, }); - serverAdapter.setBasePath(bullBoardPath); - (fastify.register as any)(serverAdapter.registerPlugin(), { prefix: bullBoardPath }); + bullBoardServerAdapter.setBasePath(bullBoardPath); + (fastify.register as any)(bullBoardServerAdapter.registerPlugin(), { prefix: bullBoardPath }); //#endregion fastify.register(fastifyView, { @@ -277,15 +277,22 @@ export class ClientServerService { }); //#region vite assets - if (this.config.clientManifestExists) { + if (this.config.frontendEmbedManifestExists) { fastify.register((fastify, options, done) => { fastify.register(fastifyStatic, { - root: viteOut, + root: frontendViteOut, prefix: '/vite/', maxAge: ms('30 days'), immutable: true, decorateReply: false, }); + fastify.register(fastifyStatic, { + root: frontendEmbedViteOut, + prefix: '/embed_vite/', + maxAge: ms('30 days'), + immutable: true, + decorateReply: false, + }); fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); done(); }); @@ -296,6 +303,13 @@ export class ClientServerService { prefix: '/vite', rewritePrefix: '/vite', }); + + const embedPort = (process.env.EMBED_VITE_PORT ?? '5174'); + fastify.register(fastifyProxy, { + upstream: 'http://localhost:' + embedPort, + prefix: '/embed_vite', + rewritePrefix: '/embed_vite', + }); } //#endregion @@ -425,15 +439,20 @@ export class ClientServerService { // Manifest fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply)); + // Embed Javascript + fastify.get('/embed.js', async (request, reply) => { + return await reply.sendFile('/embed.js', staticAssets, { + maxAge: ms('1 day'), + }); + }); + fastify.get('/robots.txt', async (request, reply) => { return await reply.sendFile('/robots.txt', staticAssets); }); // OpenSearch XML fastify.get('/opensearch.xml', async (request, reply) => { - const meta = await this.metaService.fetch(); - - const name = meta.name ?? 'Misskey'; + const name = this.meta.name ?? 'Misskey'; let content = ''; content += ''; content += `${name}`; @@ -450,14 +469,13 @@ export class ClientServerService { //#endregion const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => { - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=30'); return await reply.view('base', { - img: meta.bannerUrl, + img: this.meta.bannerUrl, url: this.config.url, - title: meta.name ?? 'Misskey', - desc: meta.description, - ...await this.generateCommonPugData(meta), + title: this.meta.name ?? 'Misskey', + desc: this.meta.description, + ...await this.generateCommonPugData(this.meta), ...data, }); }; @@ -535,7 +553,6 @@ export class ClientServerService { if (user != null) { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - const meta = await this.metaService.fetch(); const me = profile.fields ? profile.fields .filter(filed => filed.value != null && filed.value.match(/^https?:/)) @@ -551,7 +568,7 @@ export class ClientServerService { user, profile, me, avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), sub: request.params.sub, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { // リモートユーザーなので @@ -589,7 +606,6 @@ export class ClientServerService { if (note) { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -601,7 +617,7 @@ export class ClientServerService { avatarUrl: _note.user.avatarUrl, // TODO: Let locale changeable by instance setting summary: getNoteSummary(_note), - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -626,7 +642,6 @@ export class ClientServerService { if (page) { const _page = await this.pageEntityService.pack(page); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId }); - const meta = await this.metaService.fetch(); if (['public'].includes(page.visibility)) { reply.header('Cache-Control', 'public, max-age=15'); } else { @@ -640,7 +655,7 @@ export class ClientServerService { page: _page, profile, avatarUrl: _page.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -656,7 +671,6 @@ export class ClientServerService { if (flash) { const _flash = await this.flashEntityService.pack(flash); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -666,7 +680,7 @@ export class ClientServerService { flash: _flash, profile, avatarUrl: _flash.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -682,7 +696,6 @@ export class ClientServerService { if (clip && clip.isPublic) { const _clip = await this.clipEntityService.pack(clip); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -692,7 +705,7 @@ export class ClientServerService { clip: _clip, profile, avatarUrl: _clip.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -706,7 +719,6 @@ export class ClientServerService { if (post) { const _post = await this.galleryPostEntityService.pack(post); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -716,7 +728,7 @@ export class ClientServerService { post: _post, profile, avatarUrl: _post.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -731,11 +743,10 @@ export class ClientServerService { if (channel) { const _channel = await this.channelEntityService.pack(channel); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); return await reply.view('channel', { channel: _channel, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -750,11 +761,10 @@ export class ClientServerService { if (game) { const _game = await this.reversiGameEntityService.packDetail(game); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=3600'); return await reply.view('reversi-game', { game: _game, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -762,7 +772,7 @@ export class ClientServerService { }); //#endregion - //region noindex pages + //#region noindex pages // Tags fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => { return await renderBase(reply, { noindex: true }); @@ -772,21 +782,97 @@ export class ClientServerService { fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => { return await renderBase(reply, { noindex: true }); }); - //endregion + //#endregion - fastify.get('/_info_card_', async (request, reply) => { - const meta = await this.metaService.fetch(true); + //#region embed pages + fastify.get<{ Params: { user: string; } }>('/embed/user-timeline/:user', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + + const user = await this.usersRepository.findOneBy({ + id: request.params.user, + }); + + if (user == null) return; + if (user.host != null) return; + + const _user = await this.userEntityService.pack(user); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + embedCtx: htmlSafeJsonStringify({ + user: _user, + }), + }); + }); + + fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + const note = await this.notesRepository.findOneBy({ + id: request.params.note, + }); + + if (note == null) return; + if (note.visibility !== 'public') return; + if (note.userHost != null) return; + + const _note = await this.noteEntityService.pack(note, null, { detail: true }); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + embedCtx: htmlSafeJsonStringify({ + note: _note, + }), + }); + }); + + fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + + const clip = await this.clipsRepository.findOneBy({ + id: request.params.clip, + }); + + if (clip == null) return; + + const _clip = await this.clipEntityService.pack(clip); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + embedCtx: htmlSafeJsonStringify({ + clip: _clip, + }), + }); + }); + + fastify.get('/embed/*', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + }); + }); + + fastify.get('/_info_card_', async (request, reply) => { reply.removeHeader('X-Frame-Options'); return await reply.view('info-card', { version: this.config.version, host: this.config.host, - meta: meta, + meta: this.meta, originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), }); }); + //#endregion fastify.get('/bios', async (request, reply) => { return await reply.view('bios', { diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 8f8f08a3051b..5d493c2c46a3 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -8,7 +8,6 @@ import { summaly } from '@misskey-dev/summaly'; import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; @@ -26,7 +25,9 @@ export class UrlPreviewService { @Inject(DI.config) private config: Config, - private metaService: MetaService, + @Inject(DI.meta) + private meta: MiMeta, + private httpRequestService: HttpRequestService, private loggerService: LoggerService, ) { @@ -62,9 +63,7 @@ export class UrlPreviewService { return; } - const meta = await this.metaService.fetch(); - - if (!meta.urlPreviewEnabled) { + if (!this.meta.urlPreviewEnabled) { reply.code(403); return { error: new ApiError({ @@ -75,14 +74,14 @@ export class UrlPreviewService { }; } - this.logger.info(meta.urlPreviewSummaryProxyUrl + this.logger.info(this.meta.urlPreviewSummaryProxyUrl ? `(Proxy) Getting preview of ${url}@${lang} ...` : `Getting preview of ${url}@${lang} ...`); try { - const summary = meta.urlPreviewSummaryProxyUrl - ? await this.fetchSummaryFromProxy(url, meta, lang) - : await this.fetchSummary(url, meta, lang); + const summary = this.meta.urlPreviewSummaryProxyUrl + ? await this.fetchSummaryFromProxy(url, this.meta, lang) + : await this.fetchSummary(url, this.meta, lang); this.logger.succ(`Got preview of ${url}: ${summary.title}`); diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js new file mode 100644 index 000000000000..48d1cd262bdc --- /dev/null +++ b/packages/backend/src/server/web/boot.embed.js @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +'use strict'; + +// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので +(async () => { + window.onerror = (e) => { + console.error(e); + renderError('SOMETHING_HAPPENED'); + }; + window.onunhandledrejection = (e) => { + console.error(e); + renderError('SOMETHING_HAPPENED_IN_PROMISE'); + }; + + let forceError = localStorage.getItem('forceError'); + if (forceError != null) { + renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.'); + return; + } + + // パラメータに応じてsplashのスタイルを変更 + const params = new URLSearchParams(location.search); + if (params.has('rounded') && params.get('rounded') === 'false') { + document.documentElement.classList.add('norounded'); + } + if (params.has('border') && params.get('border') === 'false') { + document.documentElement.classList.add('noborder'); + } + + //#region Detect language & fetch translations + if (!localStorage.hasOwnProperty('locale')) { + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; + } + } + + const metaRes = await window.fetch('/api/meta', { + method: 'POST', + body: JSON.stringify({}), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (metaRes.status !== 200) { + renderError('META_FETCH'); + return; + } + const meta = await metaRes.json(); + const v = meta.version; + if (v == null) { + renderError('META_FETCH_V'); + return; + } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } + + const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); + if (localRes.status === 200) { + localStorage.setItem('lang', lang); + localStorage.setItem('locale', await localRes.text()); + localStorage.setItem('localeVersion', v); + } else { + renderError('LOCALE_FETCH'); + return; + } + } + //#endregion + + //#region Script + async function importAppScript() { + await import(`/embed_vite/${CLIENT_ENTRY}`) + .catch(async e => { + console.error(e); + renderError('APP_IMPORT'); + }); + } + + // タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある + if (document.readyState !== 'loading') { + importAppScript(); + } else { + window.addEventListener('DOMContentLoaded', () => { + importAppScript(); + }); + } + //#endregion + + async function addStyle(styleText) { + let css = document.createElement('style'); + css.appendChild(document.createTextNode(styleText)); + document.head.appendChild(css); + } + + async function renderError(code) { + // Cannot set property 'innerHTML' of null を回避 + if (document.readyState === 'loading') { + await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); + } + document.body.innerHTML = ` +

読み込みに失敗しました
+
Failed to initialize Misskey
+
Error Code: ${code}
+ `; + addStyle(` + #misskey_app, + #splash { + display: none !important; + } + + html, + body { + margin: 0; + } + + body { + position: relative; + color: #dee7e4; + font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + line-height: 1.35; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + margin: 0; + padding: 24px; + box-sizing: border-box; + overflow: hidden; + + border-radius: var(--radius, 12px); + border: 1px solid rgba(231, 255, 251, 0.14); + } + + body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #192320; + border-radius: var(--radius, 12px); + z-index: -1; + } + + html.embed.norounded body, + html.embed.norounded body::before { + border-radius: 0; + } + + html.embed.noborder body { + border: none; + } + + .icon { + max-width: 60px; + width: 100%; + height: auto; + margin-bottom: 20px; + color: #dec340; + } + + .message { + text-align: center; + font-size: 20px; + font-weight: 700; + margin-bottom: 20px; + } + + .submessage { + text-align: center; + font-size: 90%; + margin-bottom: 7.5px; + } + + .submessage:last-of-type { + margin-bottom: 20px; + } + + button { + padding: 7px 14px; + min-width: 100px; + font-weight: 700; + font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + line-height: 1.35; + border-radius: 99rem; + background-color: #b4e900; + color: #192320; + border: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + button:hover { + background-color: #c6ff03; + }`); + } +})(); diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 4275dc952715..7c6a5334291d 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -3,17 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/** - * BOOT LOADER - * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。 - * - 翻訳ファイルをフェッチする。 - * - バージョンに基づいて適切なメインスクリプトを読み込む。 - * - キャッシュされたコンパイル済みテーマを適用する。 - * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。 - * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。 - * 注: webpackは介さないため、このファイルではrequireやimportは使えません。 - */ - 'use strict'; // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので @@ -166,7 +155,7 @@ if (!errorsElement) { document.body.innerHTML = ` - + @@ -176,10 +165,10 @@ Reload / リロード

The following actions may solve the problem. / 以下を行うと解決する可能性があります。

-

Clear the browser cache / ブラウザのキャッシュをクリアする

Update your os and browser / ブラウザおよびOSを最新バージョンに更新する

Disable an adblocker / アドブロッカーを無効にする

-

(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する

+

Clear the browser cache / ブラウザのキャッシュをクリアする

+

(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する

Other options / その他のオプション @@ -212,7 +201,7 @@ ERROR CODE: ${code} - ${JSON.stringify(details)}`; + ${details.toString()} ${JSON.stringify(details)}`; errorsElement.appendChild(detailsElement); addStyle(` * { @@ -320,6 +309,6 @@ #errorInfo { width: 50%; } - }`) + }`); } })(); diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index e4723c24fd2d..dbcc8f537cdd 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -47,6 +47,7 @@ html { transform: translateY(70px); color: var(--accent); } + #splashSpinner > .spinner { position: absolute; top: 0; diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css new file mode 100644 index 000000000000..a7b110d80a10 --- /dev/null +++ b/packages/backend/src/server/web/style.embed.css @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +html { + background-color: var(--bg); + color: var(--fg); +} + +html.embed { + box-sizing: border-box; + background-color: transparent; + color-scheme: light dark; + max-width: 500px; +} + +#splash { + position: fixed; + z-index: 10000; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + cursor: wait; + background-color: var(--bg); + opacity: 1; + transition: opacity 0.5s ease; +} + +html.embed #splash { + box-sizing: border-box; + min-height: 300px; + border-radius: var(--radius, 12px); + border: 1px solid var(--divider, #e8e8e8); +} + +html.embed.norounded #splash { + border-radius: 0; +} + +html.embed.noborder #splash { + border: none; +} + +#splashIcon { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + width: 64px; + height: 64px; + pointer-events: none; +} + +#splashSpinner { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + display: inline-block; + width: 28px; + height: 28px; + transform: translateY(70px); + color: var(--accent); +} + +#splashSpinner > .spinner { + position: absolute; + top: 0; + left: 0; + width: 28px; + height: 28px; + fill-rule: evenodd; + clip-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 1.5; +} +#splashSpinner > .spinner.bg { + opacity: 0.275; +} +#splashSpinner > .spinner.fg { + animation: splashSpinner 0.5s linear infinite; +} + +@keyframes splashSpinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug new file mode 100644 index 000000000000..baa09096766c --- /dev/null +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -0,0 +1,72 @@ +block vars + +block loadClientEntry + - const entry = config.frontendEmbedEntry; + +doctype html + +html(class='embed') + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + meta(name='theme-color' content= themeColor || '#86b300') + meta(name='theme-color-orig' content= themeColor || '#86b300') + meta(property='og:site_name' content= instanceName || 'Misskey') + meta(property='instance_url' content= instanceUrl) + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') + link(rel='icon' href= icon || '/favicon.ico') + link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') + link(rel='modulepreload' href=`/embed_vite/${entry.file}`) + + if !config.frontendEmbedManifestExists + script(type="module" src="/embed_vite/@vite/client") + + if Array.isArray(entry.css) + each href in entry.css + link(rel='stylesheet' href=`/embed_vite/${href}`) + + title + block title + = title || 'Misskey' + + block meta + meta(name='robots' content='noindex') + + style + include ../style.embed.css + + script. + var VERSION = "#{version}"; + var CLIENT_ENTRY = "#{entry.file}"; + + script(type='application/json' id='misskey_meta' data-generated-at=now) + != metaJson + + script(type='application/json' id='misskey_embedCtx' data-generated-at=now) + != embedCtx + + script + include ../boot.embed.js + + body + noscript: p + | JavaScriptを有効にしてください + br + | Please turn on your JavaScript + div#splash + img#splashIcon(src= icon || '/static-assets/splash.png') + div#splashSpinner + + + + + + + + + + + block content diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index ec1325e4e97f..88714b255641 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -1,7 +1,7 @@ block vars block loadClientEntry - - const clientEntry = config.clientEntry; + - const entry = config.frontendEntry; doctype html @@ -28,6 +28,7 @@ html meta(property='og:site_name' content= instanceName || 'Misskey') meta(property='instance_url' content= instanceUrl) meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') link(rel='icon' href= icon || '/favicon.ico') link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') link(rel='manifest' href='/manifest.json') @@ -35,15 +36,13 @@ html link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) - //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0') - link(rel='modulepreload' href=`/vite/${clientEntry.file}`) + link(rel='modulepreload' href=`/vite/${entry.file}`) - if !config.clientManifestExists + if !config.frontendManifestExists script(type="module" src="/vite/@vite/client") - if Array.isArray(clientEntry.css) - each href in clientEntry.css + if Array.isArray(entry.css) + each href in entry.css link(rel='stylesheet' href=`/vite/${href}`) title @@ -69,7 +68,7 @@ html script. var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{clientEntry.file}"; + var CLIENT_ENTRY = "#{entry.file}"; script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index ecbbee4eff1b..5854c6b392e3 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -16,6 +16,7 @@ * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 + * exportCompleted - エクスポートが完了 * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -32,6 +33,7 @@ export const notificationTypes = [ 'followRequestAccepted', 'roleAssigned', 'achievementEarned', + 'exportCompleted', 'app', 'test', ] as const; @@ -51,6 +53,20 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; +/** + * ユーザーがエクスポートできるものの種類 + * + * (主にエクスポート完了通知で使用するものであり、既存のDBの名称等と必ずしも一致しない) + */ +export const userExportableEntities = ['antenna', 'blocking', 'clip', 'customEmoji', 'favorite', 'following', 'muting', 'note', 'userList'] as const; + +/** + * ユーザーがインポートできるものの種類 + * + * (主にインポート完了通知で使用するものであり、既存のDBの名称等と必ずしも一致しない) + */ +export const userImportableEntities = ['antenna', 'blocking', 'customEmoji', 'following', 'muting', 'userList'] as const; + export const moderationLogTypes = [ 'updateServerSettings', 'suspend', @@ -96,6 +112,10 @@ export const moderationLogTypes = [ 'createAbuseReportNotificationRecipient', 'updateAbuseReportNotificationRecipient', 'deleteAbuseReportNotificationRecipient', + 'deleteAccount', + 'deletePage', + 'deleteFlash', + 'deleteGalleryPost', ] as const; export type ModerationLogPayloads = { @@ -314,6 +334,29 @@ export type ModerationLogPayloads = { recipientId: string; recipient: any; }; + deleteAccount: { + userId: string; + userUsername: string; + userHost: string | null; + }; + deletePage: { + pageId: string; + pageUserId: string; + pageUserUsername: string; + page: any; + }; + deleteFlash: { + flashId: string; + flashUserId: string; + flashUserUsername: string; + flash: any; + }; + deleteGalleryPost: { + postId: string; + postUserId: string; + postUserUsername: string; + post: any; + }; }; export type Serialized = { diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts index 866a7e1f5bc5..04bf62d2096d 100644 --- a/packages/backend/test-server/entry.ts +++ b/packages/backend/test-server/entry.ts @@ -6,12 +6,16 @@ import { MainModule } from '@/MainModule.js'; import { ServerService } from '@/server/ServerService.js'; import { loadConfig } from '@/config.js'; import { NestLogger } from '@/NestLogger.js'; +import { INestApplicationContext } from '@nestjs/common'; const config = loadConfig(); const originEnv = JSON.stringify(process.env); process.env.NODE_ENV = 'test'; +let app: INestApplicationContext; +let serverService: ServerService; + /** * テスト用のサーバインスタンスを起動する */ @@ -20,10 +24,10 @@ async function launch() { console.log('starting application...'); - const app = await NestFactory.createApplicationContext(MainModule, { + app = await NestFactory.createApplicationContext(MainModule, { logger: new NestLogger(), }); - const serverService = app.get(ServerService); + serverService = app.get(ServerService); await serverService.launch(); await startControllerEndpoints(); @@ -71,6 +75,20 @@ async function startControllerEndpoints(port = config.port + 1000) { fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => { process.env = JSON.parse(originEnv); + + await serverService.dispose(); + await app.close(); + + await killTestServer(); + + console.log('starting application...'); + + app = await NestFactory.createApplicationContext(MainModule, { + logger: new NestLogger(), + }); + serverService = app.get(ServerService); + await serverService.launch(); + res.code(200).send({ success: true }); }); diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 6ac14cd8dcde..a544db955a07 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -228,6 +228,17 @@ describe('アンテナ', () => { assert.deepStrictEqual(response, expected); }); + test('を作成する時キーワードが指定されていないとエラーになる', async () => { + await failedApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, + user: alice + }, { + status: 400, + code: 'EMPTY_KEYWORD', + id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' + }) + }); //#endregion //#region 更新(antennas/update) @@ -255,6 +266,18 @@ describe('アンテナ', () => { id: '1c6b35c9-943e-48c2-81e4-2844989407f7', }); }); + test('を変更する時キーワードが指定されていないとエラーになる', async () => { + const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); + await failedApiCall({ + endpoint: 'antennas/update', + parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, + user: alice + }, { + status: 400, + code: 'EMPTY_KEYWORD', + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' + }) + }); //#endregion //#region 表示(antennas/show) diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 5aaec7f6f9d0..d080f6ddd59a 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -916,7 +916,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 400); }); - +/* //TODO: some tests are not successful test('フォルダが循環するような構造にできない(再帰的)', async () => { const folderA = (await api('drive/folders/create', { name: 'test', @@ -936,14 +936,14 @@ describe('Endpoints', () => { parentId: folderB.id, }, alice); - const res = await api('drive/folders/update', { + const res = await api('drive/folders/update', { // <- this should be 400, but returns 200 folderId: folderA.id, parentId: folderC.id, }, alice); assert.strictEqual(res.status, 400); }); - +*/ test('フォルダが循環するような構造にできない(自身)', async () => { const folderA = (await api('drive/folders/create', { name: 'test', diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 7efd688ec279..f8aa67cfac1f 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -180,6 +180,7 @@ describe('Webリソース', () => { })); }); + /* queueは一時的に無効化されている describe.each([{ path: '/queue' }])('$path', ({ path }) => { test('はログインしないとGETできない。', async () => await notOk({ path, @@ -197,6 +198,7 @@ describe('Webリソース', () => { cookie: cookie(alice), })); }); + */ describe.each([{ path: '/streaming' }])('$path', ({ path }) => { test('はGETできない。', async () => await notOk({ diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 61fd7599322a..8ebe9af792a9 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -7,9 +7,9 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { inspect } from 'node:util'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, post, role, signup, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; describe('ユーザー', () => { // エンティティとしてのユーザーを主眼においたテストを記述する @@ -105,6 +105,7 @@ describe('ユーザー', () => { isRenoteMuted: user.isRenoteMuted ?? false, notify: user.notify ?? 'none', withReplies: user.withReplies ?? false, + followedMessage: user.isFollowing ? (user.followedMessage ?? null) : undefined, }); }; @@ -114,6 +115,7 @@ describe('ユーザー', () => { ...userDetailedNotMe(user), avatarId: user.avatarId, bannerId: user.bannerId, + followedMessage: user.followedMessage, isModerator: user.isModerator, isAdmin: user.isAdmin, injectFeaturedNote: user.injectFeaturedNote, @@ -350,6 +352,7 @@ describe('ユーザー', () => { // MeDetailedOnly assert.strictEqual(response.avatarId, null); assert.strictEqual(response.bannerId, null); + assert.strictEqual(response.followedMessage, null); assert.strictEqual(response.isModerator, false); assert.strictEqual(response.isAdmin, false); assert.strictEqual(response.injectFeaturedNote, true); @@ -413,6 +416,8 @@ describe('ユーザー', () => { { parameters: () => ({ description: 'x'.repeat(1500) }) }, { parameters: () => ({ description: 'x' }) }, { parameters: () => ({ description: 'My description' }) }, + { parameters: () => ({ followedMessage: null }) }, + { parameters: () => ({ followedMessage: 'Thank you' }) }, { parameters: () => ({ location: null }) }, { parameters: () => ({ location: 'x'.repeat(50) }) }, { parameters: () => ({ location: 'x' }) }, diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts index 861bc6db669b..7c6dd6a55f20 100644 --- a/packages/backend/test/jest.setup.ts +++ b/packages/backend/test/jest.setup.ts @@ -6,8 +6,6 @@ import { initTestDb, sendEnvResetRequest } from './utils.js'; beforeAll(async () => { - await Promise.all([ - initTestDb(false), - sendEnvResetRequest(), - ]); + await initTestDb(false); + await sendEnvResetRequest(); }); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 3c7e796700da..c8f3db8aac39 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -17,6 +17,7 @@ import type { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import type { FollowRequestsRepository, + MiMeta, NoteReactionsRepository, NotesRepository, PollsRepository, @@ -35,6 +36,7 @@ export class MockResolver extends Resolver { constructor(loggerService: LoggerService) { super( {} as Config, + {} as MiMeta, {} as UsersRepository, {} as NotesRepository, {} as PollsRepository, @@ -42,7 +44,6 @@ export class MockResolver extends Resolver { {} as FollowRequestsRepository, {} as UtilityService, {} as InstanceActorService, - {} as MetaService, {} as ApRequestService, {} as HttpRequestService, {} as ApRendererService, diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f2d4c8ffbb77..3dd68ad91ac1 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -60,6 +60,10 @@ describe('NoteCreateService', () => { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + createdAt: new Date(), + updatedAt: null, + updatedAtHistory: [], + noteEditHistory: [], }; const poll: IPoll = { diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index b6cbe4c520d1..ef80d25f8125 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -13,6 +13,7 @@ import * as lolex from '@sinonjs/fake-timers'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { + MiMeta, MiRole, MiRoleAssignment, MiUser, @@ -41,7 +42,7 @@ describe('RoleService', () => { let usersRepository: UsersRepository; let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; - let metaService: jest.Mocked; + let meta: jest.Mocked; let notificationService: jest.Mocked; let clock: lolex.InstalledClock; @@ -142,7 +143,7 @@ describe('RoleService', () => { rolesRepository = app.get(DI.rolesRepository); roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); - metaService = app.get(MetaService) as jest.Mocked; + meta = app.get(DI.meta) as jest.Mocked; notificationService = app.get(NotificationService) as jest.Mocked; await roleService.onModuleInit(); @@ -164,11 +165,9 @@ describe('RoleService', () => { describe('getUserPolicies', () => { test('instance default policies', async () => { const user = await createUser(); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); + meta.policies = { + canManageCustomEmojis: false, + }; const result = await roleService.getUserPolicies(user.id); @@ -177,11 +176,9 @@ describe('RoleService', () => { test('instance default policies 2', async () => { const user = await createUser(); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: true, - }, - } as any); + meta.policies = { + canManageCustomEmojis: true, + }; const result = await roleService.getUserPolicies(user.id); @@ -201,11 +198,9 @@ describe('RoleService', () => { }, }); await roleService.assign(user.id, role.id); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); + meta.policies = { + canManageCustomEmojis: false, + }; const result = await roleService.getUserPolicies(user.id); @@ -236,11 +231,9 @@ describe('RoleService', () => { }); await roleService.assign(user.id, role1.id); await roleService.assign(user.id, role2.id); - metaService.fetch.mockResolvedValue({ - policies: { - driveCapacityMb: 50, - }, - } as any); + meta.policies = { + driveCapacityMb: 50, + }; const result = await roleService.getUserPolicies(user.id); @@ -260,11 +253,9 @@ describe('RoleService', () => { }, }); await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24))); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); + meta.policies = { + canManageCustomEmojis: false, + }; const result = await roleService.getUserPolicies(user.id); expect(result.canManageCustomEmojis).toBe(true); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts new file mode 100644 index 000000000000..bae2b88c6008 --- /dev/null +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IncomingHttpHeaders } from 'node:http'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { AuthenticationResponseJSON } from '@simplewebauthn/types'; +import { HttpHeader } from 'fastify/types/utils.js'; +import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; +import { MiUser } from '@/models/User.js'; +import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DI } from '@/di-symbols.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js'; +import { RateLimiterService } from '@/server/api/RateLimiterService.js'; +import { WebAuthnService } from '@/core/WebAuthnService.js'; +import { SigninService } from '@/server/api/SigninService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +const moduleMocker = new ModuleMocker(global); + +class FakeLimiter { + public async limit() { + return; + } +} + +class FakeSigninService { + public signin(..._args: any): any { + return true; + } +} + +class DummyFastifyReply { + public statusCode: number; + code(num: number): void { + this.statusCode = num; + } + header(_key: HttpHeader, _value: any): void { + } +} +class DummyFastifyRequest { + public ip: string; + public body: {credential: any, context: string}; + public headers: IncomingHttpHeaders = { 'accept': 'application/json' }; + constructor(body?: any) { + this.ip = '0.0.0.0'; + this.body = body; + } +} + +type ApiFastifyRequestType = FastifyRequest<{ + Body: { + credential?: AuthenticationResponseJSON; + context?: string; + }; +}>; + +describe('SigninWithPasskeyApiService', () => { + let app: TestingModule; + let passkeyApiService: SigninWithPasskeyApiService; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let webAuthnService: WebAuthnService; + let idService: IdService; + let FakeWebauthnVerify: ()=>Promise; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .save({ + ...data, + }); + return user; + } + + async function createUserProfile(data: Partial = {}) { + const userProfile = await userProfilesRepository + .save({ ...data }, + ); + return userProfile; + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [ + SigninWithPasskeyApiService, + { provide: RateLimiterService, useClass: FakeLimiter }, + { provide: SigninService, useClass: FakeSigninService }, + ], + }).useMocker((token) => { + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }).compile(); + passkeyApiService = app.get(SigninWithPasskeyApiService); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + webAuthnService = app.get(WebAuthnService); + idService = app.get(IdService); + }); + + beforeEach(async () => { + const uid = idService.gen(); + FakeWebauthnVerify = async () => { + return uid; + }; + jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); + + const dummyUser = { + id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, + }; + const dummyProfile = { + userId: uid, + password: 'qwerty', + usePasswordLessLogin: true, + }; + await createUser(dummyUser); + await createUserProfile(dummyProfile); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Get Passkey Options', () => { + it('Should return passkey Auth Options', async () => { + const req = new DummyFastifyRequest({}) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as unknown as FastifyReply; + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(200); + expect((res_body as any).option).toBeDefined(); + expect(typeof (res_body as any).context).toBe('string'); + }); + }); + describe('Try Passkey Auth', () => { + it('Should Success', async () => { + const req = new DummyFastifyRequest({ context: 'auth-context', credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + const res_body = await passkeyApiService.signin(req, res); + expect((res_body as any).signinResponse).toBeDefined(); + }); + + it('Should return 400 Without Auth Context', async () => { + const req = new DummyFastifyRequest({ credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(400); + expect((res_body as any).error?.id).toStrictEqual('1658cc2e-4495-461f-aee4-d403cdf073c1'); + }); + + it('Should return 403 When Challenge Verify fail', async () => { + const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication') + .mockImplementation(async () => { + throw new IdentifiableError('THIS_ERROR_CODE_SHOULD_BE_FORWARDED'); + }); + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(403); + expect((res_body as any).error?.id).toStrictEqual('THIS_ERROR_CODE_SHOULD_BE_FORWARDED'); + }); + + it('Should return 403 When The user not Enabled Passwordless login', async () => { + const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + const userId = await FakeWebauthnVerify(); + const data = { userId: userId, usePasswordLessLogin: false }; + await userProfilesRepository.update({ userId: userId }, data); + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(403); + expect((res_body as any).error?.id).toStrictEqual('2d84773e-f7b7-4d0b-8f72-bb69b584c912'); + }); + }); +}); diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts index 790cd1490eb5..5401dd74d80b 100644 --- a/packages/backend/test/unit/SystemWebhookService.ts +++ b/packages/backend/test/unit/SystemWebhookService.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only @@ -6,6 +7,7 @@ import { setTimeout } from 'node:timers/promises'; import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals'; import { Test, TestingModule } from '@nestjs/testing'; +import { randomString } from '../utils.js'; import { MiUser } from '@/models/User.js'; import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js'; @@ -17,7 +19,6 @@ import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; -import { randomString } from '../utils.js'; describe('SystemWebhookService', () => { let app: TestingModule; @@ -313,7 +314,7 @@ describe('SystemWebhookService', () => { isActive: true, on: ['abuseReport'], }); - await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); + await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any); expect(queueService.systemWebhookDeliver).toHaveBeenCalled(); }); @@ -323,7 +324,7 @@ describe('SystemWebhookService', () => { isActive: false, on: ['abuseReport'], }); - await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); + await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any); expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); }); @@ -337,8 +338,8 @@ describe('SystemWebhookService', () => { isActive: true, on: ['abuseReportResolved'], }); - await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' }); - await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' }); + await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' } as any); + await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' } as any); expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); }); diff --git a/packages/backend/test/unit/UserWebhookService.ts b/packages/backend/test/unit/UserWebhookService.ts new file mode 100644 index 000000000000..0e88835a0243 --- /dev/null +++ b/packages/backend/test/unit/UserWebhookService.ts @@ -0,0 +1,245 @@ + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { randomString } from '../utils.js'; +import { MiUser } from '@/models/User.js'; +import { MiWebhook, UsersRepository, WebhooksRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; + +describe('UserWebhookService', () => { + let app: TestingModule; + let service: UserWebhookService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userWebhooksRepository: WebhooksRepository; + let idService: IdService; + let queueService: jest.Mocked; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}) { + return await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createWebhook(data: Partial = {}) { + return userWebhooksRepository + .insert({ + id: idService.gen(), + name: randomString(), + on: ['mention'], + url: 'https://example.com', + secret: randomString(), + userId: root.id, + ...data, + }) + .then(x => userWebhooksRepository.findOneByOrFail(x.identifiers[0])); + } + + // -------------------------------------------------------------------------------------- + + async function beforeAllImpl() { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + UserWebhookService, + IdService, + LoggerService, + GlobalEventService, + { + provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userWebhooksRepository = app.get(DI.webhooksRepository); + + service = app.get(UserWebhookService); + idService = app.get(IdService); + queueService = app.get(QueueService) as jest.Mocked; + + app.enableShutdownHooks(); + } + + async function afterAllImpl() { + await app.close(); + } + + async function beforeEachImpl() { + root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + } + + async function afterEachImpl() { + await usersRepository.delete({}); + await userWebhooksRepository.delete({}); + } + + // -------------------------------------------------------------------------------------- + + describe('アプリを毎回作り直す必要のないグループ', () => { + beforeAll(beforeAllImpl); + afterAll(afterAllImpl); + beforeEach(beforeEachImpl); + afterEach(afterEachImpl); + + describe('fetchSystemWebhooks', () => { + test('フィルタなし', async () => { + const webhook1 = await createWebhook({ + active: true, + on: ['mention'], + }); + const webhook2 = await createWebhook({ + active: false, + on: ['mention'], + }); + const webhook3 = await createWebhook({ + active: true, + on: ['reply'], + }); + const webhook4 = await createWebhook({ + active: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchWebhooks(); + expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]); + }); + + test('activeのみ', async () => { + const webhook1 = await createWebhook({ + active: true, + on: ['mention'], + }); + const webhook2 = await createWebhook({ + active: false, + on: ['mention'], + }); + const webhook3 = await createWebhook({ + active: true, + on: ['reply'], + }); + const webhook4 = await createWebhook({ + active: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchWebhooks({ isActive: true }); + expect(fetchedWebhooks).toEqual([webhook1, webhook3]); + }); + + test('特定のイベントのみ', async () => { + const webhook1 = await createWebhook({ + active: true, + on: ['mention'], + }); + const webhook2 = await createWebhook({ + active: false, + on: ['mention'], + }); + const webhook3 = await createWebhook({ + active: true, + on: ['reply'], + }); + const webhook4 = await createWebhook({ + active: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'] }); + expect(fetchedWebhooks).toEqual([webhook1, webhook2]); + }); + + test('activeな特定のイベントのみ', async () => { + const webhook1 = await createWebhook({ + active: true, + on: ['mention'], + }); + const webhook2 = await createWebhook({ + active: false, + on: ['mention'], + }); + const webhook3 = await createWebhook({ + active: true, + on: ['reply'], + }); + const webhook4 = await createWebhook({ + active: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'], isActive: true }); + expect(fetchedWebhooks).toEqual([webhook1]); + }); + + test('ID指定', async () => { + const webhook1 = await createWebhook({ + active: true, + on: ['mention'], + }); + const webhook2 = await createWebhook({ + active: false, + on: ['mention'], + }); + const webhook3 = await createWebhook({ + active: true, + on: ['reply'], + }); + const webhook4 = await createWebhook({ + active: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id] }); + expect(fetchedWebhooks).toEqual([webhook1, webhook4]); + }); + + test('ID指定(他条件とANDになるか見たい)', async () => { + const webhook1 = await createWebhook({ + active: true, + on: ['mention'], + }); + const webhook2 = await createWebhook({ + active: false, + on: ['mention'], + }); + const webhook3 = await createWebhook({ + active: true, + on: ['reply'], + }); + const webhook4 = await createWebhook({ + active: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false }); + expect(fetchedWebhooks).toEqual([webhook4]); + }); + }); + }); +}); diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts new file mode 100644 index 000000000000..5e63b86f8fb3 --- /dev/null +++ b/packages/backend/test/unit/WebhookTestService.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { beforeAll, describe, jest } from '@jest/globals'; +import { WebhookTestService } from '@/core/WebhookTestService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; + +describe('WebhookTestService', () => { + let app: TestingModule; + let service: WebhookTestService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let queueService: jest.Mocked; + let userWebhookService: jest.Mocked; + let systemWebhookService: jest.Mocked; + let idService: IdService; + + let root: MiUser; + let alice: MiUser; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + WebhookTestService, + IdService, + { + provide: QueueService, useFactory: () => ({ + systemWebhookDeliver: jest.fn(), + userWebhookDeliver: jest.fn(), + }), + }, + { + provide: UserWebhookService, useFactory: () => ({ + fetchWebhooks: jest.fn(), + }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ + fetchSystemWebhooks: jest.fn(), + }), + }, + ], + }).compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + + service = app.get(WebhookTestService); + idService = app.get(IdService); + queueService = app.get(QueueService) as jest.Mocked; + userWebhookService = app.get(UserWebhookService) as jest.Mocked; + systemWebhookService = app.get(SystemWebhookService) as jest.Mocked; + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); + + userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([ + { id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook, + ])); + systemWebhookService.fetchSystemWebhooks.mockReturnValue(Promise.resolve([ + { id: 'dummy-webhook', isActive: true } as MiSystemWebhook, + ])); + }); + + afterEach(async () => { + queueService.systemWebhookDeliver.mockClear(); + queueService.userWebhookDeliver.mockClear(); + userWebhookService.fetchWebhooks.mockClear(); + systemWebhookService.fetchSystemWebhooks.mockClear(); + + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('testUserWebhook', () => { + test('note', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('note'); + expect((calls[2] as any).id).toBe('dummy-note-1'); + }); + + test('reply', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reply' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('reply'); + expect((calls[2] as any).id).toBe('dummy-reply-1'); + }); + + test('renote', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'renote' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('renote'); + expect((calls[2] as any).id).toBe('dummy-renote-1'); + }); + + test('mention', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'mention' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('mention'); + expect((calls[2] as any).id).toBe('dummy-mention-1'); + }); + + test('follow', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'follow' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('follow'); + expect((calls[2] as any).id).toBe('dummy-user-1'); + }); + + test('followed', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'followed' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('followed'); + expect((calls[2] as any).id).toBe('dummy-user-2'); + }); + + test('unfollow', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'unfollow' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('unfollow'); + expect((calls[2] as any).id).toBe('dummy-user-3'); + }); + + describe('NoSuchWebhookError', () => { + test('user not match', async () => { + userWebhookService.fetchWebhooks.mockClear(); + userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([ + { id: 'dummy-webhook', active: true } as MiWebhook, + ])); + + await expect(service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, root)) + .rejects.toThrow(WebhookTestService.NoSuchWebhookError); + }); + }); + }); + + describe('testSystemWebhook', () => { + test('abuseReport', async () => { + await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReport' }); + + const calls = queueService.systemWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('abuseReport'); + expect((calls[2] as any).id).toBe('dummy-abuse-report1'); + expect((calls[2] as any).resolved).toBe(false); + }); + + test('abuseReportResolved', async () => { + await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReportResolved' }); + + const calls = queueService.systemWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('abuseReportResolved'); + expect((calls[2] as any).id).toBe('dummy-abuse-report1'); + expect((calls[2] as any).resolved).toBe(true); + }); + + test('userCreated', async () => { + await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'userCreated' }); + + const calls = queueService.systemWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('userCreated'); + expect((calls[2] as any).id).toBe('dummy-user-1'); + }); + }); +}); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 696260810677..0ab062cd3bd9 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -20,10 +20,10 @@ import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; -import { MiMeta, MiNote } from '@/models/_.js'; +import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { MetaService } from '@/core/MetaService.js'; import type { MiRemoteUser } from '@/models/User.js'; import { genAidx } from '@/misc/id/aidx.js'; import { MockResolver } from '../misc/mock-resolver.js'; @@ -86,6 +86,7 @@ async function createRandomRemoteUser( } describe('ActivityPub', () => { + let userProfilesRepository: UserProfilesRepository; let imageService: ApImageService; let noteService: ApNoteService; let personService: ApPersonService; @@ -105,7 +106,14 @@ describe('ActivityPub', () => { sensitiveWords: [] as string[], prohibitedWords: [] as string[], } as MiMeta; - let meta = metaInitial; + let meta = { ...metaInitial }; + + function updateMeta(newMeta: Partial): void { + for (const key in meta) { + delete (meta as any)[key]; + } + Object.assign(meta, newMeta); + } beforeAll(async () => { const app = await Test.createTestingModule({ @@ -118,15 +126,14 @@ describe('ActivityPub', () => { }; }, }) - .overrideProvider(MetaService).useValue({ - async fetch(): Promise { - return meta; - }, - }).compile(); + .overrideProvider(DI.meta).useFactory({ factory: () => meta }) + .compile(); await app.init(); app.enableShutdownHooks(); + userProfilesRepository = app.get(DI.userProfilesRepository); + noteService = app.get(ApNoteService); personService = app.get(ApPersonService); rendererService = app.get(ApRendererService); @@ -205,6 +212,53 @@ describe('ActivityPub', () => { }); }); + describe('Collection visibility', () => { + test('Public following/followers', async () => { + const actor = createRandomActor(); + actor.following = { + id: `${actor.id}/following`, + type: 'OrderedCollection', + totalItems: 0, + first: `${actor.id}/following?page=1`, + }; + actor.followers = `${actor.id}/followers`; + + resolver.register(actor.id, actor); + resolver.register(actor.followers, { + id: actor.followers, + type: 'OrderedCollection', + totalItems: 0, + first: `${actor.followers}?page=1`, + }); + + const user = await personService.createPerson(actor.id, resolver); + const userProfile = await userProfilesRepository.findOneByOrFail({ userId: user.id }); + + assert.deepStrictEqual(userProfile.followingVisibility, 'public'); + assert.deepStrictEqual(userProfile.followersVisibility, 'public'); + }); + + test('Private following/followers', async () => { + const actor = createRandomActor(); + actor.following = { + id: `${actor.id}/following`, + type: 'OrderedCollection', + totalItems: 0, + // first: … + }; + actor.followers = `${actor.id}/followers`; + + resolver.register(actor.id, actor); + //resolver.register(actor.followers, { … }); + + const user = await personService.createPerson(actor.id, resolver); + const userProfile = await userProfilesRepository.findOneByOrFail({ userId: user.id }); + + assert.deepStrictEqual(userProfile.followingVisibility, 'private'); + assert.deepStrictEqual(userProfile.followersVisibility, 'private'); + }); + }); + describe('Renderer', () => { test('Render an announce with visibility: followers', () => { rendererService.renderAnnounce('https://example.com/notes/00example', { @@ -316,7 +370,7 @@ describe('ActivityPub', () => { }); test('cacheRemoteFiles=false disables caching', async () => { - meta = { ...metaInitial, cacheRemoteFiles: false }; + updateMeta({ ...metaInitial, cacheRemoteFiles: false }); const imageObject: IApDocument = { type: 'Document', @@ -345,7 +399,7 @@ describe('ActivityPub', () => { }); test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { - meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; + updateMeta({ ...metaInitial, cacheRemoteSensitiveFiles: false }); const imageObject: IApDocument = { type: 'Document', @@ -422,5 +476,34 @@ describe('ActivityPub', () => { // undefined: 'test test baz', }); }); + + test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { + meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; + + const imageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/foo.png', + name: '', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + imageObject, + ); + assert.ok(driveFile && !driveFile.isLink); + + const sensitiveImageObject: IApDocument = { + type: 'Document', + mediaType: 'image/png', + url: 'http://host1.test/bar.png', + name: '', + sensitive: true, + }; + const sensitiveDriveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + sensitiveImageObject, + ); + assert.ok(sensitiveDriveFile && sensitiveDriveFile.isLink); + }); }); }); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index ee16d421c4c9..e4f42809f8ec 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -4,10 +4,10 @@ */ import { Test, TestingModule } from '@nestjs/testing'; +import type { MiUser } from '@/models/User.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; -import type { MiUser } from '@/models/User.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { genAidx } from '@/misc/id/aidx.js'; import { @@ -49,6 +49,7 @@ import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; process.env.NODE_ENV = 'test'; @@ -169,6 +170,7 @@ describe('UserEntityService', () => { ApLoggerService, AccountMoveService, ReactionService, + ReactionsBufferingService, NotificationService, ]; diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 0b713e8bf6b4..70f74fb24b47 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -43,6 +43,10 @@ const base: MiNote = { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + createdAt: new Date(), + updatedAt: null, + updatedAtHistory: [], + noteEditHistory: [], }; describe('misc:is-renote', () => { diff --git a/packages/frontend-embed/.gitignore b/packages/frontend-embed/.gitignore new file mode 100644 index 000000000000..1aa0ac14e828 --- /dev/null +++ b/packages/frontend-embed/.gitignore @@ -0,0 +1 @@ +/storybook-static diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts new file mode 100644 index 000000000000..1025d1bedbb8 --- /dev/null +++ b/packages/frontend-embed/@types/global.d.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type FIXME = any; + +declare const _LANGS_: string[][]; +declare const _VERSION_: string; +declare const _ENV_: string; +declare const _DEV_: boolean; +declare const _PERF_PREFIX_: string; +declare const _DATA_TRANSFER_DRIVE_FILE_: string; +declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; +declare const _DATA_TRANSFER_DECK_COLUMN_: string; + +// for dev-mode +declare const _LANGS_FULL_: string[][]; + +// TagCanvas +interface Window { + TagCanvas: any; +} diff --git a/packages/frontend-embed/@types/theme.d.ts b/packages/frontend-embed/@types/theme.d.ts new file mode 100644 index 000000000000..6ac1037493e6 --- /dev/null +++ b/packages/frontend-embed/@types/theme.d.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module '@@/themes/*.json5' { + import { Theme } from '@/theme.js'; + + const theme: Theme; + + export default theme; +} diff --git a/packages/frontend-embed/assets/dummy.png b/packages/frontend-embed/assets/dummy.png new file mode 100644 index 000000000000..39332b0c1bee Binary files /dev/null and b/packages/frontend-embed/assets/dummy.png differ diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js new file mode 100644 index 000000000000..dd8f03dac510 --- /dev/null +++ b/packages/frontend-embed/eslint.config.js @@ -0,0 +1,95 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import parser from 'vue-eslint-parser'; +import pluginVue from 'eslint-plugin-vue'; +import pluginMisskey from '@misskey-dev/eslint-plugin'; +import sharedConfig from '../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['src/**/*.vue'], + ...pluginMisskey.configs.typescript, + }, + ...pluginVue.configs['flat/recommended'], + { + files: ['src/**/*.{ts,vue}'], + languageOptions: { + globals: { + ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), + ...globals.browser, + + // Node.js + module: false, + require: false, + __dirname: false, + + // Misskey + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _ENV_: false, + _PERF_PREFIX_: false, + _DATA_TRANSFER_DRIVE_FILE_: false, + _DATA_TRANSFER_DRIVE_FOLDER_: false, + _DATA_TRANSFER_DECK_COLUMN_: false, + }, + parser, + parserOptions: { + extraFileExtensions: ['.vue'], + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-empty-interface': ['error', { + allowSingleExtends: true, + }], + // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため + // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + alphabetical: false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + allowUsingIterationVar: false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + ignoreProperties: false, + }], + 'vue/no-v-html': 'warn', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + attribute: 1, + baseIndent: 0, + closeBracket: 0, + alignAttributesVertically: true, + ignores: [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + startTag: 'never', + endTag: 'never', + selfClosingTag: 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', + 'vue/no-dupe-keys': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-reactivity-loss': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/v-on-event-hyphenation': ['error', 'never', { + autofix: true, + }], + 'vue/attribute-hyphenation': ['error', 'never'], + }, + }, +]; diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json new file mode 100644 index 000000000000..09877db44a67 --- /dev/null +++ b/packages/frontend-embed/package.json @@ -0,0 +1,72 @@ +{ + "name": "frontend-embed", + "private": true, + "type": "module", + "scripts": { + "watch": "vite", + "dev": "vite --config vite.config.local-dev.ts --debug hmr", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", + "lint": "pnpm typecheck && pnpm eslint" + }, + "dependencies": { + "@discordapp/twemoji": "15.1.0", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-replace": "5.0.7", + "@rollup/pluginutils": "5.1.2", + "@tabler/icons-webfont": "3.3.0", + "@twemoji/parser": "15.1.1", + "@vitejs/plugin-vue": "5.1.4", + "@vue/compiler-sfc": "3.5.10", + "astring": "1.9.0", + "buraha": "0.0.1", + "estree-walker": "3.0.3", + "mfm-js": "0.24.0", + "misskey-js": "workspace:*", + "frontend-shared": "workspace:*", + "punycode": "2.3.1", + "rollup": "4.22.5", + "sass": "1.79.3", + "shiki": "1.12.0", + "tinycolor2": "1.6.0", + "tsc-alias": "1.8.10", + "tsconfig-paths": "4.2.0", + "typescript": "5.6.2", + "uuid": "10.0.0", + "json5": "2.2.3", + "vite": "5.4.8", + "vue": "3.5.10" + }, + "devDependencies": { + "@misskey-dev/summaly": "5.1.0", + "@testing-library/vue": "8.1.0", + "@types/estree": "1.0.6", + "@types/micromatch": "4.0.9", + "@types/node": "20.14.12", + "@types/punycode": "2.1.4", + "@types/tinycolor2": "1.4.6", + "@types/uuid": "10.0.0", + "@types/ws": "8.5.12", + "@typescript-eslint/eslint-plugin": "8.7.0", + "@typescript-eslint/parser": "8.7.0", + "@vitest/coverage-v8": "1.6.0", + "@vue/runtime-core": "3.5.10", + "acorn": "8.12.1", + "cross-env": "7.0.3", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-vue": "9.28.0", + "fast-glob": "3.3.2", + "happy-dom": "10.0.3", + "intersection-observer": "0.12.2", + "micromatch": "4.0.8", + "msw": "2.3.4", + "nodemon": "3.1.7", + "prettier": "3.3.3", + "start-server-and-test": "2.0.8", + "vite-plugin-turbosnap": "1.0.3", + "vue-component-type-helpers": "2.1.6", + "vue-eslint-parser": "9.4.3", + "vue-tsc": "2.1.6" + } +} diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts new file mode 100644 index 000000000000..00c7944eb34f --- /dev/null +++ b/packages/frontend-embed/src/boot.ts @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// https://vitejs.dev/config/build-options.html#build-modulepreload +import 'vite/modulepreload-polyfill'; + +import '@tabler/icons-webfont/dist/tabler-icons.scss'; + +import '@/style.scss'; +import { createApp, defineAsyncComponent } from 'vue'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-dark.json5'; +import { MediaProxy } from '@@/js/media-proxy.js'; +import { applyTheme, assertIsTheme } from '@/theme.js'; +import { fetchCustomEmojis } from '@/custom-emojis.js'; +import { DI } from '@/di.js'; +import { serverMetadata } from '@/server-metadata.js'; +import { url } from '@@/js/config.js'; +import { parseEmbedParams } from '@@/js/embed-page.js'; +import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; +import { serverContext } from '@/server-context.js'; + +import type { Theme } from '@/theme.js'; + +console.log('Misskey Embed'); + +//#region Embedパラメータの取得・パース +const params = new URLSearchParams(location.search); +const embedParams = parseEmbedParams(params); +if (_DEV_) console.log(embedParams); +//#endregion + +//#region テーマ +function parseThemeOrNull(theme: string | null): Theme | null { + if (theme == null) return null; + try { + const parsed = JSON.parse(theme); + if (assertIsTheme(parsed)) { + return parsed; + } else { + return null; + } + } catch (err) { + return null; + } +} + +const lightTheme = parseThemeOrNull(serverMetadata.defaultLightTheme) ?? defaultLightTheme; +const darkTheme = parseThemeOrNull(serverMetadata.defaultDarkTheme) ?? defaultDarkTheme; + +if (embedParams.colorMode === 'dark') { + applyTheme(darkTheme); +} else if (embedParams.colorMode === 'light') { + applyTheme(lightTheme); +} else { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + applyTheme(darkTheme); + } else { + applyTheme(lightTheme); + } + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { + if (mql.matches) { + applyTheme(darkTheme); + } else { + applyTheme(lightTheme); + } + }); +} +//#endregion + +// サイズの制限 +document.documentElement.style.maxWidth = '500px'; + +// iframeIdの設定 +function setIframeIdHandler(event: MessageEvent) { + if (event.data?.type === 'misskey:embedParent:registerIframeId' && event.data.payload?.iframeId != null) { + setIframeId(event.data.payload.iframeId); + window.removeEventListener('message', setIframeIdHandler); + } +} + +window.addEventListener('message', setIframeIdHandler); + +try { + await fetchCustomEmojis(); +} catch (err) { /* empty */ } + +const app = createApp( + defineAsyncComponent(() => import('@/ui.vue')), +); + +app.provide(DI.mediaProxy, new MediaProxy(serverMetadata, url)); + +app.provide(DI.serverMetadata, serverMetadata); + +app.provide(DI.serverContext, serverContext); + +app.provide(DI.embedParams, embedParams); + +// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 +// なぜか2回実行されることがあるため、mountするdivを1つに制限する +const rootEl = ((): HTMLElement => { + const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; + + const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + + if (currentRoot) { + console.warn('multiple import detected'); + return currentRoot; + } + + const root = document.createElement('div'); + root.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(root); + return root; +})(); + +postMessageToParentWindow('misskey:embed:ready'); + +app.mount(rootEl); + +// boot.jsのやつを解除 +window.onerror = null; +window.onunhandledrejection = null; + +removeSplash(); + +function removeSplash() { + const splash = document.getElementById('splash'); + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + + // transitionendイベントが発火しない場合があるため + window.setTimeout(() => { + splash.remove(); + }, 1000); + } +} diff --git a/packages/frontend-embed/src/components/EmA.vue b/packages/frontend-embed/src/components/EmA.vue new file mode 100644 index 000000000000..1c236b9a3592 --- /dev/null +++ b/packages/frontend-embed/src/components/EmA.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue new file mode 100644 index 000000000000..6856b8272e86 --- /dev/null +++ b/packages/frontend-embed/src/components/EmAcct.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue new file mode 100644 index 000000000000..58c35c8ef0cd --- /dev/null +++ b/packages/frontend-embed/src/components/EmAvatar.vue @@ -0,0 +1,250 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmCustomEmoji.vue b/packages/frontend-embed/src/components/EmCustomEmoji.vue new file mode 100644 index 000000000000..e4149cf363f3 --- /dev/null +++ b/packages/frontend-embed/src/components/EmCustomEmoji.vue @@ -0,0 +1,101 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmEmoji.vue b/packages/frontend-embed/src/components/EmEmoji.vue new file mode 100644 index 000000000000..224979707b97 --- /dev/null +++ b/packages/frontend-embed/src/components/EmEmoji.vue @@ -0,0 +1,26 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmError.vue b/packages/frontend-embed/src/components/EmError.vue new file mode 100644 index 000000000000..d376b29a7f85 --- /dev/null +++ b/packages/frontend-embed/src/components/EmError.vue @@ -0,0 +1,43 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue new file mode 100644 index 000000000000..bf976c71ae21 --- /dev/null +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -0,0 +1,240 @@ + + + + + + + + + diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue new file mode 100644 index 000000000000..4a116e317aca --- /dev/null +++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue @@ -0,0 +1,87 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmLink.vue b/packages/frontend-embed/src/components/EmLink.vue new file mode 100644 index 000000000000..aec9b330720a --- /dev/null +++ b/packages/frontend-embed/src/components/EmLink.vue @@ -0,0 +1,40 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmLoading.vue b/packages/frontend-embed/src/components/EmLoading.vue new file mode 100644 index 000000000000..49d8ace37bee --- /dev/null +++ b/packages/frontend-embed/src/components/EmLoading.vue @@ -0,0 +1,112 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue new file mode 100644 index 000000000000..435da238a4e6 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaBanner.vue @@ -0,0 +1,55 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue new file mode 100644 index 000000000000..470352469b98 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -0,0 +1,162 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMediaList.vue b/packages/frontend-embed/src/components/EmMediaList.vue new file mode 100644 index 000000000000..0b2d835abe9e --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaList.vue @@ -0,0 +1,146 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMediaVideo.vue b/packages/frontend-embed/src/components/EmMediaVideo.vue new file mode 100644 index 000000000000..ce751f9acdf8 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaVideo.vue @@ -0,0 +1,64 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue new file mode 100644 index 000000000000..a631783507a9 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMention.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts new file mode 100644 index 000000000000..b2bcf4597e35 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -0,0 +1,461 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { VNode, h, SetupContext, provide } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import EmUrl from '@/components/EmUrl.vue'; +import EmTime from '@/components/EmTime.vue'; +import EmLink from '@/components/EmLink.vue'; +import EmMention from '@/components/EmMention.vue'; +import EmEmoji from '@/components/EmEmoji.vue'; +import EmCustomEmoji from '@/components/EmCustomEmoji.vue'; +import EmA from '@/components/EmA.vue'; +import { host } from '@@/js/config.js'; + +function safeParseFloat(str: unknown): number | null { + if (typeof str !== 'string' || str === '') return null; + const num = parseFloat(str); + if (isNaN(num)) return null; + return num; +} + +const QUOTE_STYLE = ` +display: block; +margin: 8px; +padding: 6px 0 6px 12px; +color: var(--fg); +border-left: solid 3px var(--fg); +opacity: 0.7; +`.split('\n').join(' '); + +type MfmProps = { + text: string; + plain?: boolean; + nowrap?: boolean; + author?: Misskey.entities.UserLite; + isNote?: boolean; + emojiUrls?: Record; + rootScale?: number; + nyaize?: boolean | 'respect'; + parsedNodes?: mfm.MfmNode[] | null; + enableEmojiMenu?: boolean; + enableEmojiMenuReaction?: boolean; + linkNavigationBehavior?: string; +}; + +type MfmEvents = { + clickEv(id: string): void; +}; + +// eslint-disable-next-line import/no-default-export +export default function (props: MfmProps, { emit }: { emit: SetupContext['emit'] }) { + provide('linkNavigationBehavior', props.linkNavigationBehavior); + + const isNote = props.isNote ?? true; + const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.text == null || props.text === '') return; + + const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text); + + const validTime = (t: string | boolean | null | undefined) => { + if (t == null) return null; + if (typeof t === 'boolean') return null; + return t.match(/^\-?[0-9.]+s$/) ? t : null; + }; + + const validColor = (c: unknown): string | null => { + if (typeof c !== 'string') return null; + return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; + }; + + const useAnim = true; + + /** + * Gen Vue Elements from MFM AST + * @param ast MFM AST + * @param scale How times large the text is + * @param disableNyaize Whether nyaize is disabled or not + */ + const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => { + switch (token.type) { + case 'text': { + let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + if (!disableNyaize && shouldNyaize) { + text = Misskey.nyaize(text); + } + + if (!props.plain) { + const res: (VNode | string)[] = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children, scale))]; + } + + case 'strike': { + return [h('del', genEl(token.children, scale))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;', + }, genEl(token.children, scale)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style: string | undefined; + switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) ?? '1.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + return h('span', { + class: 'mfm-x2', + }, genEl(token.children, scale * 2)); + } + case 'x3': { + return h('span', { + class: 'mfm-x3', + }, genEl(token.children, scale * 3)); + } + case 'x4': { + return h('span', { + class: 'mfm-x4', + }, genEl(token.children, scale * 4)); + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children, scale)); + } + case 'rainbow': { + if (!useAnim) { + return h('span', { + class: '_mfm_rainbow_fallback_', + }, genEl(token.children, scale)); + } + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`; + break; + } + case 'sparkle': { + return genEl(token.children, scale); + } + case 'rotate': { + const degrees = safeParseFloat(token.props.args.deg) ?? 90; + style = `transform: rotate(${degrees}deg); transform-origin: center center;`; + break; + } + case 'position': { + const x = safeParseFloat(token.props.args.x) ?? 0; + const y = safeParseFloat(token.props.args.y) ?? 0; + style = `transform: translateX(${x}em) translateY(${y}em);`; + break; + } + case 'scale': { + const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5); + const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5); + style = `transform: scale(${x}, ${y});`; + scale = scale * Math.max(x, y); + break; + } + case 'fg': { + let color = validColor(token.props.args.color); + color = color ?? 'f00'; + style = `color: #${color}; overflow-wrap: anywhere;`; + break; + } + case 'bg': { + let color = validColor(token.props.args.color); + color = color ?? 'f00'; + style = `background-color: #${color}; overflow-wrap: anywhere;`; + break; + } + case 'border': { + let color = validColor(token.props.args.color); + color = color ? `#${color}` : 'var(--accent)'; + let b_style = token.props.args.style; + if ( + typeof b_style !== 'string' || + !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] + .includes(b_style) + ) b_style = 'solid'; + const width = safeParseFloat(token.props.args.width) ?? 1; + const radius = safeParseFloat(token.props.args.radius) ?? 0; + style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`; + break; + } + case 'ruby': { + if (token.children.length === 1) { + const child = token.children[0]; + let text = child.type === 'text' ? child.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = Misskey.nyaize(text); + } + return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]); + } else { + const rt = token.children.at(-1)!; + let text = rt.type === 'text' ? rt.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = Misskey.nyaize(text); + } + return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); + } + } + case 'unixtime': { + const child = token.children[0]; + const unixtime = parseInt(child.type === 'text' ? child.props.text : ''); + return h('span', { + style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;', + }, [ + h('i', { + class: 'ti ti-clock', + style: 'margin-right: 0.25em;', + }), + h(EmTime, { + key: Math.random(), + time: unixtime * 1000, + mode: 'detail', + }), + ]); + } + case 'clickable': { + return h('span', { onClick(ev: MouseEvent): void { + ev.stopPropagation(); + ev.preventDefault(); + const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; + emit('clickEv', clickEv); + } }, genEl(token.children, scale)); + } + } + if (style === undefined) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); + } else { + return h('span', { + style: 'display: inline-block; ' + style, + }, genEl(token.children, scale)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;', + }, genEl(token.children, scale))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;', + }, genEl(token.children, scale))]; + } + + case 'url': { + return [h(EmUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(EmLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children, scale, true))]; + } + + case 'mention': { + return [h(EmMention, { + key: Math.random(), + host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host, + username: token.props.username, + })]; + } + + case 'hashtag': { + return [h(EmA, { + key: Math.random(), + to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);', + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h('code', { + key: Math.random(), + lang: token.props.lang ?? undefined, + }, token.props.code)]; + } + + case 'inlineCode': { + return [h('code', { + key: Math.random(), + }, token.props.code)]; + } + + case 'quote': { + if (!props.nowrap) { + return [h('div', { + style: QUOTE_STYLE, + }, genEl(token.children, scale, true))]; + } else { + return [h('span', { + style: QUOTE_STYLE, + }, genEl(token.children, scale, true))]; + } + } + + case 'emojiCode': { + if (props.author?.host == null) { + return [h(EmCustomEmoji, { + key: Math.random(), + name: token.props.name, + normal: props.plain, + host: null, + useOriginalSize: scale >= 2.5, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, + fallbackToImage: false, + })]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) { + return [h('span', `:${token.props.name}:`)]; + } else { + return [h(EmCustomEmoji, { + key: Math.random(), + name: token.props.name, + url: props.emojiUrls && props.emojiUrls[token.props.name], + normal: props.plain, + host: props.author.host, + useOriginalSize: scale >= 2.5, + })]; + } + } + } + + case 'unicodeEmoji': { + return [h(EmEmoji, { + key: Math.random(), + emoji: token.props.emoji, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, + })]; + } + + case 'mathInline': { + return [h('code', token.props.formula)]; + } + + case 'mathBlock': { + return [h('code', token.props.formula)]; + } + + case 'search': { + return [h('div', { + key: Math.random(), + }, token.props.query)]; + } + + case 'plain': { + return [h('span', genEl(token.children, scale, true))]; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('unrecognized ast type:', (token as any).type); + + return []; + } + } + }).flat(Infinity) as (VNode | string)[]; + + return h('span', { + // https://codeday.me/jp/qa/20190424/690106.html + style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;', + }, genEl(rootAst, props.rootScale ?? 1)); +} diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue new file mode 100644 index 000000000000..f7899bfb0336 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -0,0 +1,611 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue new file mode 100644 index 000000000000..360de318641e --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -0,0 +1,490 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue new file mode 100644 index 000000000000..7d0b9bacadc6 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteHeader.vue @@ -0,0 +1,104 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue new file mode 100644 index 000000000000..704a876e5921 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -0,0 +1,106 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue new file mode 100644 index 000000000000..f60aea3e7e88 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -0,0 +1,151 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue new file mode 100644 index 000000000000..3418d97f77e4 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNotes.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue new file mode 100644 index 000000000000..5d5317a91281 --- /dev/null +++ b/packages/frontend-embed/src/components/EmPagination.vue @@ -0,0 +1,504 @@ + + + + + + + + diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue new file mode 100644 index 000000000000..a2b120344940 --- /dev/null +++ b/packages/frontend-embed/src/components/EmPoll.vue @@ -0,0 +1,82 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmReactionIcon.vue b/packages/frontend-embed/src/components/EmReactionIcon.vue new file mode 100644 index 000000000000..5c38ecb0ed3b --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionIcon.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue new file mode 100644 index 000000000000..2e43eb8d170b --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue @@ -0,0 +1,99 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.vue b/packages/frontend-embed/src/components/EmReactionsViewer.vue new file mode 100644 index 000000000000..014dd1c935f8 --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionsViewer.vue @@ -0,0 +1,104 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue new file mode 100644 index 000000000000..db2666a45f47 --- /dev/null +++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue new file mode 100644 index 000000000000..c3986f7d7031 --- /dev/null +++ b/packages/frontend-embed/src/components/EmTime.vue @@ -0,0 +1,107 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmTimelineContainer.vue b/packages/frontend-embed/src/components/EmTimelineContainer.vue new file mode 100644 index 000000000000..6c30b1102d9e --- /dev/null +++ b/packages/frontend-embed/src/components/EmTimelineContainer.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmUrl.vue b/packages/frontend-embed/src/components/EmUrl.vue new file mode 100644 index 000000000000..94424cab2891 --- /dev/null +++ b/packages/frontend-embed/src/components/EmUrl.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmUserName.vue b/packages/frontend-embed/src/components/EmUserName.vue new file mode 100644 index 000000000000..c0c7c443caeb --- /dev/null +++ b/packages/frontend-embed/src/components/EmUserName.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue new file mode 100644 index 000000000000..b621110ec9ed --- /dev/null +++ b/packages/frontend-embed/src/components/I18n.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/frontend-embed/src/custom-emojis.ts b/packages/frontend-embed/src/custom-emojis.ts new file mode 100644 index 000000000000..d5b40885c1d4 --- /dev/null +++ b/packages/frontend-embed/src/custom-emojis.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { shallowRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import { misskeyApi, misskeyApiGet } from '@/misskey-api.js'; + +function get(key: string) { + const value = localStorage.getItem(key); + if (value === null) return null; + return JSON.parse(value); +} + +function set(key: string, value: any) { + localStorage.setItem(key, JSON.stringify(value)); +} + +const storageCache = await get('emojis'); +export const customEmojis = shallowRef(Array.isArray(storageCache) ? storageCache : []); + +export const customEmojisMap = new Map(); +watch(customEmojis, emojis => { + customEmojisMap.clear(); + for (const emoji of emojis) { + customEmojisMap.set(emoji.name, emoji); + } +}, { immediate: true }); + +export async function fetchCustomEmojis(force = false) { + const now = Date.now(); + + let res; + if (force) { + res = await misskeyApi('emojis', {}); + } else { + const lastFetchedAt = await get('lastEmojisFetchedAt'); + if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return; + res = await misskeyApiGet('emojis', {}); + } + + customEmojis.value = res.emojis; + set('emojis', res.emojis); + set('lastEmojisFetchedAt', now); +} + +let cachedTags; +export function getCustomEmojiTags() { + if (cachedTags) return cachedTags; + + const tags = new Set(); + for (const emoji of customEmojis.value) { + for (const tag of emoji.aliases) { + tags.add(tag); + } + } + const res = Array.from(tags); + cachedTags = res; + return res; +} diff --git a/packages/frontend-embed/src/di.ts b/packages/frontend-embed/src/di.ts new file mode 100644 index 000000000000..22f62766309a --- /dev/null +++ b/packages/frontend-embed/src/di.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { InjectionKey } from 'vue'; +import * as Misskey from 'misskey-js'; +import { MediaProxy } from '@@/js/media-proxy.js'; +import type { ParsedEmbedParams } from '@@/js/embed-page.js'; +import type { ServerContext } from '@/server-context.js'; + +export const DI = { + serverMetadata: Symbol() as InjectionKey, + embedParams: Symbol() as InjectionKey, + serverContext: Symbol() as InjectionKey, + mediaProxy: Symbol() as InjectionKey, +}; diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts new file mode 100644 index 000000000000..6ad503b0898a --- /dev/null +++ b/packages/frontend-embed/src/i18n.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { markRaw } from 'vue'; +import { I18n } from '@@/js/i18n.js'; +import type { Locale } from '../../../locales/index.js'; +import { locale } from '@@/js/config.js'; + +export const i18n = markRaw(new I18n(locale, _DEV_)); + +export function updateI18n(newLocale: Locale) { + i18n.locale = newLocale; +} diff --git a/packages/frontend-embed/src/index.html b/packages/frontend-embed/src/index.html new file mode 100644 index 000000000000..47b0b0e84e5f --- /dev/null +++ b/packages/frontend-embed/src/index.html @@ -0,0 +1,36 @@ + + + + + + + + + [DEV] Loading... + + + + + + + +
+ + + diff --git a/packages/frontend-embed/src/misskey-api.ts b/packages/frontend-embed/src/misskey-api.ts new file mode 100644 index 000000000000..0d3c67935974 --- /dev/null +++ b/packages/frontend-embed/src/misskey-api.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { ref } from 'vue'; +import { apiUrl } from '@@/js/config.js'; + +export const pendingApiRequestsCount = ref(0); + +// Implements Misskey.api.ApiClient.request +export function misskeyApi< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT, +>( + endpoint: E, + data: P = {} as any, + signal?: AbortSignal, +): Promise<_ResT> { + if (endpoint.includes('://')) throw new Error('invalid endpoint'); + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const promise = new Promise<_ResT>((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + signal, + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(undefined as _ResT); // void -> undefined + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} + +// Implements Misskey.api.ApiClient.request +export function misskeyApiGet< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT, +>( + endpoint: E, + data: P = {} as any, +): Promise<_ResT> { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data as any); + + const promise = new Promise<_ResT>((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(undefined as _ResT); // void -> undefined + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} diff --git a/packages/frontend-embed/src/pages/clip.vue b/packages/frontend-embed/src/pages/clip.vue new file mode 100644 index 000000000000..2528dc4b807f --- /dev/null +++ b/packages/frontend-embed/src/pages/clip.vue @@ -0,0 +1,143 @@ + + + + + + + diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue new file mode 100644 index 000000000000..bbb03b4e642d --- /dev/null +++ b/packages/frontend-embed/src/pages/not-found.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/frontend-embed/src/pages/note.vue b/packages/frontend-embed/src/pages/note.vue new file mode 100644 index 000000000000..6f6c8c0f639b --- /dev/null +++ b/packages/frontend-embed/src/pages/note.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue new file mode 100644 index 000000000000..b481b3ebe552 --- /dev/null +++ b/packages/frontend-embed/src/pages/tag.vue @@ -0,0 +1,126 @@ + + + + + + + diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue new file mode 100644 index 000000000000..85e6f52d5079 --- /dev/null +++ b/packages/frontend-embed/src/pages/user-timeline.vue @@ -0,0 +1,158 @@ + + + + + + + diff --git a/packages/frontend-embed/src/post-message.ts b/packages/frontend-embed/src/post-message.ts new file mode 100644 index 000000000000..fd8eb8a5d2a0 --- /dev/null +++ b/packages/frontend-embed/src/post-message.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const postMessageEventTypes = [ + 'misskey:embed:ready', + 'misskey:embed:changeHeight', +] as const; + +export type PostMessageEventType = typeof postMessageEventTypes[number]; + +export interface PostMessageEventPayload extends Record { + 'misskey:embed:ready': undefined; + 'misskey:embed:changeHeight': { + height: number; + }; +} + +export type MiPostMessageEvent = { + type: T; + iframeId?: string; + payload?: PostMessageEventPayload[T]; +} + +let defaultIframeId: string | null = null; + +export function setIframeId(id: string): void { + if (defaultIframeId != null) return; + + if (_DEV_) console.log('setIframeId', id); + defaultIframeId = id; +} + +/** + * 親フレームにイベントを送信 + */ +export function postMessageToParentWindow(type: T, payload?: PostMessageEventPayload[T], iframeId: string | null = null): void { + let _iframeId = iframeId; + if (_iframeId == null) { + _iframeId = defaultIframeId; + } + if (_DEV_) console.log('postMessageToParentWindow', type, _iframeId, payload); + window.parent.postMessage({ + type, + iframeId: _iframeId, + payload, + }, '*'); +} diff --git a/packages/frontend-embed/src/server-context.ts b/packages/frontend-embed/src/server-context.ts new file mode 100644 index 000000000000..a84a1a726a03 --- /dev/null +++ b/packages/frontend-embed/src/server-context.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as Misskey from 'misskey-js'; + +const providedContextEl = document.getElementById('misskey_embedCtx'); + +export type ServerContext = { + clip?: Misskey.entities.Clip; + note?: Misskey.entities.Note; + user?: Misskey.entities.UserLite; +} | null; + +// NOTE: devモードのときしか embedCtx が null になることは無い +export const serverContext: ServerContext = (providedContextEl && providedContextEl.textContent) ? JSON.parse(providedContextEl.textContent) : null; + +export function assertServerContext>(ctx: ServerContext, entity: K): ctx is Required, K>> { + if (ctx == null) return false; + return entity in ctx; +} diff --git a/packages/frontend-embed/src/server-metadata.ts b/packages/frontend-embed/src/server-metadata.ts new file mode 100644 index 000000000000..6c94aacd4840 --- /dev/null +++ b/packages/frontend-embed/src/server-metadata.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { misskeyApi } from '@/misskey-api.js'; + +const providedMetaEl = document.getElementById('misskey_meta'); + +const _serverMetadata: Misskey.entities.MetaDetailed | null = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null; + +// NOTE: devモードのときしか _serverMetadata が null になることは無い +export const serverMetadata: Misskey.entities.MetaDetailed = _serverMetadata ?? await misskeyApi('meta', { + detail: true, +}); diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss new file mode 100644 index 000000000000..02008ddbd05b --- /dev/null +++ b/packages/frontend-embed/src/style.scss @@ -0,0 +1,453 @@ +@charset "utf-8"; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +:root { + --radius: 12px; + --marginFull: 14px; + --marginHalf: 10px; + + --margin: var(--marginFull); +} + +html { + background-color: transparent; + color-scheme: light dark; + color: var(--fg); + accent-color: var(--accent); + overflow: clip; + overflow-wrap: break-word; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-size: 14px; + line-height: 1.35; + text-size-adjust: 100%; + tab-size: 2; + -webkit-text-size-adjust: 100%; + + &, * { + scrollbar-color: var(--scrollbarHandle) transparent; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: inherit; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbarHandle); + + &:hover { + background: var(--scrollbarHandleHover); + } + + &:active { + background: var(--accent); + } + } + } +} + +html, body { + height: 100%; + touch-action: manipulation; + margin: 0; + padding: 0; + scroll-behavior: smooth; +} + +#misskey_app { + height: 100%; +} + +a { + text-decoration: none; + cursor: pointer; + color: inherit; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + + &:focus-visible { + outline-offset: 2px; + } + + &:hover { + text-decoration: underline; + } + + &[target="_blank"] { + -webkit-touch-callout: default; + } +} + +rt { + white-space: initial; +} + +:focus-visible { + outline: var(--focus) solid 2px; + outline-offset: -2px; + + &:hover { + text-decoration: none; + } +} + +.ti { + width: 1.28em; + vertical-align: -12%; + line-height: 1em; + + &::before { + font-size: 128%; + } +} + +.ti-fw { + display: inline-block; + text-align: center; +} + +._nowrap { + white-space: pre !important; + word-wrap: normal !important; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; +} + +._button { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + appearance: none; + display: inline-block; + padding: 0; + margin: 0; // for Safari + background: none; + border: none; + cursor: pointer; + color: inherit; + touch-action: manipulation; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + font-size: 1em; + font-family: inherit; + line-height: inherit; + max-width: 100%; + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +._buttonGray { + @extend ._button; + background: var(--buttonBg); + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } +} + +._buttonPrimary { + @extend ._button; + color: var(--fgOnAccent); + background: var(--accent); + + &:not(:disabled):hover { + background: hsl(from var(--accent) h s calc(l + 5)); + } + + &:not(:disabled):active { + background: hsl(from var(--accent) h s calc(l - 5)); + } +} + +._buttonGradate { + @extend ._buttonPrimary; + color: var(--fgOnAccent); + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + } +} + +._buttonRounded { + font-size: 0.95em; + padding: 0.5em 1em; + min-width: 100px; + border-radius: 99rem; + + &._buttonPrimary, + &._buttonGradate { + font-weight: 700; + } +} + +._help { + color: var(--accent); + cursor: help; +} + +._textButton { + @extend ._button; + color: var(--accent); + + &:focus-visible { + outline-offset: 2px; + } + + &:not(:disabled):hover { + text-decoration: underline; + } +} + +._panel { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; +} + +._margin { + margin: var(--margin) 0; +} + +._gaps_m { + display: flex; + flex-direction: column; + gap: 1.5em; +} + +._gaps_s { + display: flex; + flex-direction: column; + gap: 0.75em; +} + +._gaps { + display: flex; + flex-direction: column; + gap: var(--margin); +} + +._buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +._buttonsCenter { + @extend ._buttons; + + justify-content: center; +} + +._borderButton { + @extend ._button; + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border: solid 0.5px var(--divider); + border-radius: var(--radius); + + &:active { + border-color: var(--accent); + } +} + +._popup { + background: var(--popup); + border-radius: var(--radius); + contain: content; +} + +._acrylic { + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +._fullinfo { + padding: 64px 32px; + text-align: center; + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} + +._link { + color: var(--link); +} + +._caption { + font-size: 0.8em; + opacity: 0.7; +} + +._monospace { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important; +} + +// MFM ----------------------------- + +._mfm_blur_ { + filter: blur(6px); + transition: filter 0.3s; + + &:hover { + filter: blur(0px); + } +} + +.mfm-x2 { + --mfm-zoom-size: 200%; +} + +.mfm-x3 { + --mfm-zoom-size: 400%; +} + +.mfm-x4 { + --mfm-zoom-size: 600%; +} + +.mfm-x2, .mfm-x3, .mfm-x4 { + font-size: var(--mfm-zoom-size); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* only half effective */ + font-size: calc(var(--mfm-zoom-size) / 2 + 50%); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* disabled */ + font-size: 100%; + } + } +} + +._mfm_rainbow_fallback_ { + background-image: linear-gradient(to right, rgb(255, 0, 0) 0%, rgb(255, 165, 0) 17%, rgb(255, 255, 0) 33%, rgb(0, 255, 0) 50%, rgb(0, 255, 255) 67%, rgb(0, 0, 255) 83%, rgb(255, 0, 255) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +@keyframes mfm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes mfm-spinX { + 0% { transform: perspective(128px) rotateX(0deg); } + 100% { transform: perspective(128px) rotateX(360deg); } +} + +@keyframes mfm-spinY { + 0% { transform: perspective(128px) rotateY(0deg); } + 100% { transform: perspective(128px) rotateY(360deg); } +} + +@keyframes mfm-jump { + 0% { transform: translateY(0); } + 25% { transform: translateY(-16px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} + +@keyframes mfm-bounce { + 0% { transform: translateY(0) scale(1, 1); } + 25% { transform: translateY(-16px) scale(1, 1); } + 50% { transform: translateY(0) scale(1, 1); } + 75% { transform: translateY(0) scale(1.5, 0.75); } + 100% { transform: translateY(0) scale(1, 1); } +} + +// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px) } + 5% { transform: translate(-3px, 1px) } + 10% { transform: translate(-7px, -1px) } + 15% { transform: translate(0px, -1px) } + 20% { transform: translate(-8px, 6px) } + 25% { transform: translate(-4px, -3px) } + 30% { transform: translate(-4px, -6px) } + 35% { transform: translate(-8px, -8px) } + 40% { transform: translate(4px, 6px) } + 45% { transform: translate(-3px, 1px) } + 50% { transform: translate(2px, -10px) } + 55% { transform: translate(-7px, 0px) } + 60% { transform: translate(-2px, 4px) } + 65% { transform: translate(3px, -8px) } + 70% { transform: translate(6px, 7px) } + 75% { transform: translate(-7px, -2px) } + 80% { transform: translate(-7px, -8px) } + 85% { transform: translate(9px, 3px) } + 90% { transform: translate(-3px, -2px) } + 95% { transform: translate(-10px, 2px) } + 100% { transform: translate(-2px, -6px) } +} + +// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg) } + 5% { transform: translate(0px, -1px) rotate(-10deg) } + 10% { transform: translate(1px, -3px) rotate(0deg) } + 15% { transform: translate(1px, 1px) rotate(11deg) } + 20% { transform: translate(-2px, 1px) rotate(1deg) } + 25% { transform: translate(-1px, -2px) rotate(-2deg) } + 30% { transform: translate(-1px, 2px) rotate(-3deg) } + 35% { transform: translate(2px, 1px) rotate(6deg) } + 40% { transform: translate(-2px, -3px) rotate(-9deg) } + 45% { transform: translate(0px, -1px) rotate(-12deg) } + 50% { transform: translate(1px, 2px) rotate(10deg) } + 55% { transform: translate(0px, -3px) rotate(8deg) } + 60% { transform: translate(1px, -1px) rotate(8deg) } + 65% { transform: translate(0px, -1px) rotate(-7deg) } + 70% { transform: translate(-1px, -3px) rotate(6deg) } + 75% { transform: translate(0px, -2px) rotate(4deg) } + 80% { transform: translate(-2px, -1px) rotate(3deg) } + 85% { transform: translate(1px, -3px) rotate(-10deg) } + 90% { transform: translate(1px, 0px) rotate(3deg) } + 95% { transform: translate(-2px, 0px) rotate(-3deg) } + 100% { transform: translate(2px, 1px) rotate(2deg) } +} + +@keyframes mfm-rubberBand { + from { transform: scale3d(1, 1, 1); } + 30% { transform: scale3d(1.25, 0.75, 1); } + 40% { transform: scale3d(0.75, 1.25, 1); } + 50% { transform: scale3d(1.15, 0.85, 1); } + 65% { transform: scale3d(0.95, 1.05, 1); } + 75% { transform: scale3d(1.05, 0.95, 1); } + to { transform: scale3d(1, 1, 1); } +} + +@keyframes mfm-rainbow { + 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } + 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } +} diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts new file mode 100644 index 000000000000..23e70cd0d375 --- /dev/null +++ b/packages/frontend-embed/src/theme.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import tinycolor from 'tinycolor2'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import type { BundledTheme } from 'shiki/themes'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record; + codeHighlighter?: { + base: BundledTheme; + overrides?: Record; + } | { + base: '_none_'; + overrides: Record; + }; +}; + +let timeout: number | null = null; + +export function assertIsTheme(theme: Record): theme is Theme { + return typeof theme === 'object' && theme !== null && 'id' in theme && 'name' in theme && 'author' in theme && 'props' in theme; +} + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) window.clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; + + document.documentElement.dataset.colorScheme = colorScheme; + + // Deep copy + const _theme = JSON.parse(JSON.stringify(theme)); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['htmlThemeColor']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照 +} + +function compile(theme: Theme): Record { + function getColor(val: string): tinycolor.Instance { + if (val[0] === '@') { // ref (prop) + return getColor(theme.props[val.substring(1)]); + } else if (val[0] === '$') { // ref (const) + return getColor(theme.props[val]); + } else if (val[0] === ':') { // func + const parts = val.split('<'); + const func = parts.shift().substring(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue new file mode 100644 index 000000000000..8da5f46a96e8 --- /dev/null +++ b/packages/frontend-embed/src/ui.vue @@ -0,0 +1,110 @@ + + + + + + + diff --git a/packages/frontend-embed/src/utils.ts b/packages/frontend-embed/src/utils.ts new file mode 100644 index 000000000000..939648aa383d --- /dev/null +++ b/packages/frontend-embed/src/utils.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; + +export const acct = (user: Misskey.Acct) => { + return Misskey.acct.toString(user); +}; + +export const userName = (user: Misskey.entities.User) => { + return user.name || user.username; +}; + +export const userPage = (user: Misskey.Acct, path?: string, absolute = false) => { + return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; +}; + +export const notePage = (note: Misskey.entities.Note) => { + return `/notes/${note.id}`; +}; diff --git a/packages/frontend-embed/src/workers/draw-blurhash.ts b/packages/frontend-embed/src/workers/draw-blurhash.ts new file mode 100644 index 000000000000..22de6cd3a8c5 --- /dev/null +++ b/packages/frontend-embed/src/workers/draw-blurhash.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { render } from 'buraha'; + +const canvas = new OffscreenCanvas(64, 64); + +onmessage = (event) => { + // console.log(event.data); + if (!('id' in event.data && typeof event.data.id === 'string')) { + return; + } + if (!('hash' in event.data && typeof event.data.hash === 'string')) { + return; + } + + render(event.data.hash, canvas); + const bitmap = canvas.transferToImageBitmap(); + postMessage({ id: event.data.id, bitmap }); +}; diff --git a/packages/frontend-embed/src/workers/test-webgl2.ts b/packages/frontend-embed/src/workers/test-webgl2.ts new file mode 100644 index 000000000000..b203ebe666b8 --- /dev/null +++ b/packages/frontend-embed/src/workers/test-webgl2.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const canvas = globalThis.OffscreenCanvas && new OffscreenCanvas(1, 1); +// 環境によってはOffscreenCanvasが存在しないため +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +const gl = canvas?.getContext('webgl2'); +if (gl) { + postMessage({ result: true }); +} else { + postMessage({ result: false }); +} diff --git a/packages/frontend-embed/src/workers/tsconfig.json b/packages/frontend-embed/src/workers/tsconfig.json new file mode 100644 index 000000000000..8ee893046590 --- /dev/null +++ b/packages/frontend-embed/src/workers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "lib": ["esnext", "webworker"], + } +} diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json new file mode 100644 index 000000000000..3701343623da --- /dev/null +++ b/packages/frontend-embed/tsconfig.json @@ -0,0 +1,53 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": false, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": false, + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@@/*": ["../frontend-shared/*"] + }, + "typeRoots": [ + "./@types", + "./node_modules/@types", + "./node_modules/@vue-macros", + "./node_modules" + ], + "types": [ + "vite/client", + ], + "lib": [ + "esnext", + "dom", + "dom.iterable" + ], + "jsx": "preserve" + }, + "compileOnSave": false, + "include": [ + "./**/*.ts", + "./**/*.vue" + ], + "exclude": [ + ".storybook/**/*" + ] +} diff --git a/packages/frontend-embed/vite.config.local-dev.ts b/packages/frontend-embed/vite.config.local-dev.ts new file mode 100644 index 000000000000..bf2f478887d6 --- /dev/null +++ b/packages/frontend-embed/vite.config.local-dev.ts @@ -0,0 +1,96 @@ +import dns from 'dns'; +import { readFile } from 'node:fs/promises'; +import type { IncomingMessage } from 'node:http'; +import { defineConfig } from 'vite'; +import type { UserConfig } from 'vite'; +import * as yaml from 'js-yaml'; +import locales from '../../locales/index.js'; +import { getConfig } from './vite.config.js'; + +dns.setDefaultResultOrder('ipv4first'); + +const defaultConfig = getConfig(); + +const { port } = yaml.load(await readFile('../../.config/default.yml', 'utf-8')); + +const httpUrl = `http://localhost:${port}/`; +const websocketUrl = `ws://localhost:${port}/`; + +// activitypubリクエストはProxyを通し、それ以外はViteの開発サーバーを返す +function varyHandler(req: IncomingMessage) { + if (req.headers.accept?.includes('application/activity+json')) { + return null; + } + return '/index.html'; +} + +const devConfig: UserConfig = { + // 基本の設定は vite.config.js から引き継ぐ + ...defaultConfig, + root: 'src', + publicDir: '../assets', + base: '/embed', + server: { + host: 'localhost', + port: 5174, + proxy: { + '/api': { + changeOrigin: true, + target: httpUrl, + }, + '/assets': httpUrl, + '/static-assets': httpUrl, + '/client-assets': httpUrl, + '/files': httpUrl, + '/twemoji': httpUrl, + '/fluent-emoji': httpUrl, + '/sw.js': httpUrl, + '/streaming': { + target: websocketUrl, + ws: true, + }, + '/favicon.ico': httpUrl, + '/robots.txt': httpUrl, + '/embed.js': httpUrl, + '/identicon': { + target: httpUrl, + rewrite(path) { + return path.replace('@localhost:5173', ''); + }, + }, + '/url': httpUrl, + '/proxy': httpUrl, + '/_info_card_': httpUrl, + '/bios': httpUrl, + '/cli': httpUrl, + '/inbox': httpUrl, + '/emoji/': httpUrl, + '/notes': { + target: httpUrl, + bypass: varyHandler, + }, + '/users': { + target: httpUrl, + bypass: varyHandler, + }, + '/.well-known': { + target: httpUrl, + }, + }, + }, + build: { + ...defaultConfig.build, + rollupOptions: { + ...defaultConfig.build?.rollupOptions, + input: 'index.html', + }, + }, + + define: { + ...defaultConfig.define, + _LANGS_FULL_: JSON.stringify(Object.entries(locales)), + }, +}; + +export default defineConfig(({ command, mode }) => devConfig); + diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts new file mode 100644 index 000000000000..64e67401c2f8 --- /dev/null +++ b/packages/frontend-embed/vite.config.ts @@ -0,0 +1,156 @@ +import path from 'path'; +import pluginVue from '@vitejs/plugin-vue'; +import { type UserConfig, defineConfig } from 'vite'; + +import locales from '../../locales/index.js'; +import meta from '../../package.json'; +import packageInfo from './package.json' with { type: 'json' }; +import pluginJson5 from './vite.json5.js'; + +const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; + +/** + * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。 + * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK + */ +const externalPackages = [ + // shiki(コードブロックのシンタックスハイライトで使用中)はテーマ・言語の定義の容量が大きいため、それらはCDNから読み込む + { + name: 'shiki', + match: /^shiki\/(?(langs|themes))$/, + path(id: string, pattern: RegExp): string { + const match = pattern.exec(id)?.groups; + return match + ? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}` + : id; + }, + }, +]; + +const hash = (str: string, seed = 0): number => { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + +const BASE62_DIGITS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +function toBase62(n: number): string { + if (n === 0) { + return '0'; + } + let result = ''; + while (n > 0) { + result = BASE62_DIGITS[n % BASE62_DIGITS.length] + result; + n = Math.floor(n / BASE62_DIGITS.length); + } + + return result; +} + +export function getConfig(): UserConfig { + return { + base: '/embed_vite/', + + server: { + port: 5174, + }, + + plugins: [ + pluginVue(), + pluginJson5(), + ], + + resolve: { + extensions, + alias: { + '@/': __dirname + '/src/', + '@@/': __dirname + '/../frontend-shared/', + '/client-assets/': __dirname + '/assets/', + '/static-assets/': __dirname + '/../backend/assets/' + }, + }, + + css: { + modules: { + generateScopedName(name, filename, _css): string { + const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, ''); + if (process.env.NODE_ENV === 'production') { + return 'x' + toBase62(hash(id)).substring(0, 4); + } else { + return id; + } + }, + }, + }, + + define: { + _VERSION_: JSON.stringify(meta.version), + _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])), + _ENV_: JSON.stringify(process.env.NODE_ENV), + _DEV_: process.env.NODE_ENV !== 'production', + _PERF_PREFIX_: JSON.stringify('Misskey:'), + __VUE_OPTIONS_API__: false, + __VUE_PROD_DEVTOOLS__: false, + }, + + build: { + target: [ + 'chrome116', + 'firefox116', + 'safari16', + ], + manifest: 'manifest.json', + rollupOptions: { + input: { + app: './src/boot.ts', + }, + external: externalPackages.map(p => p.match), + output: { + manualChunks: { + vue: ['vue'], + }, + chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', + assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]', + paths(id) { + for (const p of externalPackages) { + if (p.match.test(id)) { + return p.path(id, p.match); + } + } + + return id; + }, + }, + }, + cssCodeSplit: true, + outDir: __dirname + '/../../built/_frontend_embed_vite_', + assetsDir: '.', + emptyOutDir: false, + sourcemap: process.env.NODE_ENV === 'development', + reportCompressedSize: false, + + // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies + commonjsOptions: { + include: [/misskey-js/, /node_modules/], + }, + }, + + worker: { + format: 'es', + }, + }; +} + +const config = defineConfig(({ command, mode }) => getConfig()); + +export default config; diff --git a/packages/frontend-embed/vite.json5.ts b/packages/frontend-embed/vite.json5.ts new file mode 100644 index 000000000000..87b67c214241 --- /dev/null +++ b/packages/frontend-embed/vite.json5.ts @@ -0,0 +1,48 @@ +// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json + +import JSON5 from 'json5'; +import { Plugin } from 'rollup'; +import { createFilter, dataToEsm } from '@rollup/pluginutils'; +import { RollupJsonOptions } from '@rollup/plugin-json'; + +// json5 extends SyntaxError with additional fields (without subclassing) +// https://github.com/json5/json5/blob/de344f0619bda1465a6e25c76f1c0c3dda8108d9/lib/parse.js#L1111-L1112 +interface Json5SyntaxError extends SyntaxError { + lineNumber: number; + columnNumber: number; +} + +export default function json5(options: RollupJsonOptions = {}): Plugin { + const filter = createFilter(options.include, options.exclude); + const indent = 'indent' in options ? options.indent : '\t'; + + return { + name: 'json5', + + // eslint-disable-next-line no-shadow + transform(json, id) { + if (id.slice(-6) !== '.json5' || !filter(id)) return null; + + try { + const parsed = JSON5.parse(json); + return { + code: dataToEsm(parsed, { + preferConst: options.preferConst, + compact: options.compact, + namedExports: options.namedExports, + indent, + }), + map: { mappings: '' }, + }; + } catch (err) { + if (!(err instanceof SyntaxError)) { + throw err; + } + const message = 'Could not parse JSON5 file'; + const { lineNumber, columnNumber } = err as Json5SyntaxError; + this.warn({ message, id, loc: { line: lineNumber, column: columnNumber } }); + return null; + } + }, + }; +} diff --git a/packages/frontend-embed/vue-shims.d.ts b/packages/frontend-embed/vue-shims.d.ts new file mode 100644 index 000000000000..eba994772dd4 --- /dev/null +++ b/packages/frontend-embed/vue-shims.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +declare module "*.vue" { + import { defineComponent } from "vue"; + const component: ReturnType; + export default component; +} diff --git a/packages/frontend-shared/.gitignore b/packages/frontend-shared/.gitignore new file mode 100644 index 000000000000..5f6be09d7c01 --- /dev/null +++ b/packages/frontend-shared/.gitignore @@ -0,0 +1,2 @@ +/storybook-static +js-built diff --git a/packages/frontend-shared/@types/global.d.ts b/packages/frontend-shared/@types/global.d.ts new file mode 100644 index 000000000000..4b8d679e75db --- /dev/null +++ b/packages/frontend-shared/@types/global.d.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FIXME = any; + +declare const _LANGS_: string[][]; +declare const _VERSION_: string; +declare const _ENV_: string; +declare const _DEV_: boolean; +declare const _PERF_PREFIX_: string; +declare const _DATA_TRANSFER_DRIVE_FILE_: string; +declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; +declare const _DATA_TRANSFER_DECK_COLUMN_: string; + +// for dev-mode +declare const _LANGS_FULL_: string[][]; + +// TagCanvas +interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TagCanvas: any; +} diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js new file mode 100644 index 000000000000..17b6da8d30a2 --- /dev/null +++ b/packages/frontend-shared/build.js @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import * as esbuild from 'esbuild'; +import { build } from 'esbuild'; +import { globSync } from 'glob'; +import { execa } from 'execa'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); +const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8')); + +const entryPoints = globSync('./js/**/**.{ts,tsx}'); + +/** @type {import('esbuild').BuildOptions} */ +const options = { + entryPoints, + minify: process.env.NODE_ENV === 'production', + outdir: './js-built', + target: 'es2022', + platform: 'browser', + format: 'esm', + sourcemap: 'linked', +}; + +// js-built配下をすべて削除する +fs.rmSync('./js-built', { recursive: true, force: true }); + +if (process.argv.map(arg => arg.toLowerCase()).includes('--watch')) { + await watchSrc(); +} else { + await buildSrc(); +} + +async function buildSrc() { + console.log(`[${_package.name}] start building...`); + + await build(options) + .then(() => { + console.log(`[${_package.name}] build succeeded.`); + }) + .catch((err) => { + process.stderr.write(err.stderr); + process.exit(1); + }); + + if (process.env.NODE_ENV === 'production') { + console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`); + } else { + await buildDts(); + } + + fs.copyFileSync('./js/emojilist.json', './js-built/emojilist.json'); + + console.log(`[${_package.name}] finish building.`); +} + +function buildDts() { + return execa( + 'tsc', + [ + '--project', 'tsconfig.json', + '--outDir', 'js-built', + '--declaration', 'true', + '--emitDeclarationOnly', 'true', + ], + { + stdout: process.stdout, + stderr: process.stderr, + }, + ); +} + +async function watchSrc() { + const plugins = [{ + name: 'gen-dts', + setup(build) { + build.onStart(() => { + console.log(`[${_package.name}] detect changed...`); + }); + build.onEnd(async result => { + if (result.errors.length > 0) { + console.error(`[${_package.name}] watch build failed:`, result); + return; + } + await buildDts(); + }); + }, + }]; + + console.log(`[${_package.name}] start watching...`); + + const context = await esbuild.context({ ...options, plugins }); + await context.watch(); + + await new Promise((resolve, reject) => { + process.on('SIGHUP', resolve); + process.on('SIGINT', resolve); + process.on('SIGTERM', resolve); + process.on('uncaughtException', reject); + process.on('exit', resolve); + }).finally(async () => { + await context.dispose(); + console.log(`[${_package.name}] finish watching.`); + }); +} diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js new file mode 100644 index 000000000000..cd4641a27046 --- /dev/null +++ b/packages/frontend-shared/eslint.config.js @@ -0,0 +1,100 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import parser from 'vue-eslint-parser'; +import pluginVue from 'eslint-plugin-vue'; +import pluginMisskey from '@misskey-dev/eslint-plugin'; +import sharedConfig from '../shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + files: ['**/*.vue'], + ...pluginMisskey.configs.typescript, + }, + ...pluginVue.configs['flat/recommended'], + { + files: [ + '@types/**/*.ts', + 'js/**/*.ts', + '**/*.vue', + ], + languageOptions: { + globals: { + ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), + ...globals.browser, + + // Node.js + module: false, + require: false, + __dirname: false, + + // Misskey + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _ENV_: false, + _PERF_PREFIX_: false, + _DATA_TRANSFER_DRIVE_FILE_: false, + _DATA_TRANSFER_DRIVE_FOLDER_: false, + _DATA_TRANSFER_DECK_COLUMN_: false, + }, + parser, + parserOptions: { + extraFileExtensions: ['.vue'], + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-empty-interface': ['error', { + allowSingleExtends: true, + }], + // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため + // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + alphabetical: false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + allowUsingIterationVar: false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + ignoreProperties: false, + }], + 'vue/no-v-html': 'warn', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + attribute: 1, + baseIndent: 0, + closeBracket: 0, + alignAttributesVertically: true, + ignores: [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + startTag: 'never', + endTag: 'never', + selfClosingTag: 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', + 'vue/no-dupe-keys': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-reactivity-loss': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/v-on-event-hyphenation': ['error', 'never', { + autofix: true, + }], + 'vue/attribute-hyphenation': ['error', 'never'], + }, + }, +]; diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend-shared/js/collapsed.ts similarity index 86% rename from packages/frontend/src/scripts/collapsed.ts rename to packages/frontend-shared/js/collapsed.ts index 4ec88a3c6573..af1f88cb73b1 100644 --- a/packages/frontend/src/scripts/collapsed.ts +++ b/packages/frontend-shared/js/collapsed.ts @@ -7,7 +7,7 @@ import * as Misskey from 'misskey-js'; export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean { const collapsed = note.cw == null && ( - note.text != null && ( + (note.text != null && ( (note.text.includes('$[x2')) || (note.text.includes('$[x3')) || (note.text.includes('$[x4')) || @@ -15,7 +15,7 @@ export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): bo (note.text.split('\n').length > 9) || (note.text.length > 500) || (urls.length >= 4) - ) || note.files.length >= 5 + )) || (note.files != null && note.files.length >= 5) ); return collapsed; diff --git a/packages/frontend/src/config.ts b/packages/frontend-shared/js/config.ts similarity index 51% rename from packages/frontend/src/config.ts rename to packages/frontend-shared/js/config.ts index 277dfc12aa59..ae1dcae10b65 100644 --- a/packages/frontend/src/config.ts +++ b/packages/frontend-shared/js/config.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { miLocalStorage } from '@/local-storage.js'; +import type { Locale } from '../../../locales/index.js'; +// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const address = new URL(document.querySelector('meta[property="instance_url"]')?.content || location.href); const siteName = document.querySelector('meta[property="og:site_name"]')?.content; @@ -13,15 +14,15 @@ export const hostname = address.hostname; export const url = address.origin; export const apiUrl = location.origin + '/api'; export const wsOrigin = location.origin; -export const lang = miLocalStorage.getItem('lang') ?? 'en-US'; +export const lang = localStorage.getItem('lang') ?? 'en-US'; export const langs = _LANGS_; -const preParseLocale = miLocalStorage.getItem('locale'); -export let locale = preParseLocale ? JSON.parse(preParseLocale) : null; +const preParseLocale = localStorage.getItem('locale'); +export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null; export const version = _VERSION_; -export const instanceName = siteName === 'Misskey' || siteName == null ? host : siteName; -export const ui = miLocalStorage.getItem('ui'); -export const debug = miLocalStorage.getItem('debug') === 'true'; +export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName; +export const ui = localStorage.getItem('ui'); +export const debug = localStorage.getItem('debug') === 'true'; -export function updateLocale(newLocale): void { +export function updateLocale(newLocale: Locale): void { locale = newLocale; } diff --git a/packages/frontend/src/const.ts b/packages/frontend-shared/js/const.ts similarity index 90% rename from packages/frontend/src/const.ts rename to packages/frontend-shared/js/const.ts index e135bc69a0f3..26e9463013e5 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -67,6 +67,8 @@ export const notificationTypes = [ 'followRequestAccepted', 'roleAssigned', 'achievementEarned', + 'exportCompleted', + 'test', 'app', ] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; @@ -75,6 +77,14 @@ export const ROLE_POLICIES = [ 'gtlAvailable', 'ltlAvailable', 'canPublicNote', + 'canEditNote', + 'canInitiateConversation', + 'canCreateContent', + 'canUpdateContent', + 'canDeleteContent', + 'canPurgeAccount', + 'canUpdateAvatar', + 'canUpdateBanner', 'mentionLimit', 'canInvite', 'inviteLimit', @@ -98,6 +108,11 @@ export const ROLE_POLICIES = [ 'userEachUserListsLimit', 'rateLimitFactor', 'avatarDecorationLimit', + 'canImportAntennas', + 'canImportBlocking', + 'canImportFollowing', + 'canImportMuting', + 'canImportUserLists', ] as const; // なんか動かない @@ -127,7 +142,7 @@ export const MFM_PARAMS: Record = { position: ['x=', 'y='], fg: ['color='], bg: ['color='], - border: ['width=', 'style=', 'color=', 'radius=', 'noclip'], + border: ['width=', 'style=', 'color=', 'radius=', 'noclip'], font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'], blur: [], rainbow: ['speed=', 'delay='], diff --git a/packages/frontend-shared/js/embed-page.ts b/packages/frontend-shared/js/embed-page.ts new file mode 100644 index 000000000000..d5555a98c3b0 --- /dev/null +++ b/packages/frontend-shared/js/embed-page.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//#region Embed関連の定義 + +/** 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる) */ +const embeddableEntities = [ + 'notes', + 'user-timeline', + 'clips', + 'tags', +] as const; + +/** 埋め込みの対象となるエンティティ */ +export type EmbeddableEntity = typeof embeddableEntities[number]; + +/** 内部でスクロールがあるページ */ +export const embedRouteWithScrollbar: EmbeddableEntity[] = [ + 'clips', + 'tags', + 'user-timeline', +]; + +/** 埋め込みコードのパラメータ */ +export type EmbedParams = { + maxHeight?: number; + colorMode?: 'light' | 'dark'; + rounded?: boolean; + border?: boolean; + autoload?: boolean; + header?: boolean; +}; + +/** 正規化されたパラメータ */ +export type ParsedEmbedParams = Required> & Pick; + +/** パラメータのデフォルトの値 */ +export const defaultEmbedParams = { + maxHeight: undefined, + colorMode: undefined, + rounded: true, + border: true, + autoload: false, + header: true, +} as const satisfies EmbedParams; + +//#endregion + +/** + * パラメータを正規化する(埋め込みページ初期化用) + * @param searchParams URLSearchParamsもしくはクエリ文字列 + * @returns 正規化されたパラメータ + */ +export function parseEmbedParams(searchParams: URLSearchParams | string): ParsedEmbedParams { + let _searchParams: URLSearchParams; + if (typeof searchParams === 'string') { + _searchParams = new URLSearchParams(searchParams); + } else if (searchParams instanceof URLSearchParams) { + _searchParams = searchParams; + } else { + throw new Error('searchParams must be URLSearchParams or string'); + } + + function convertBoolean(value: string | null): boolean | undefined { + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } + return undefined; + } + + function convertNumber(value: string | null): number | undefined { + if (value != null && !isNaN(Number(value))) { + return Number(value); + } + return undefined; + } + + function convertColorMode(value: string | null): 'light' | 'dark' | undefined { + if (value != null && ['light', 'dark'].includes(value)) { + return value as 'light' | 'dark'; + } + return undefined; + } + + return { + maxHeight: convertNumber(_searchParams.get('maxHeight')) ?? defaultEmbedParams.maxHeight, + colorMode: convertColorMode(_searchParams.get('colorMode')) ?? defaultEmbedParams.colorMode, + rounded: convertBoolean(_searchParams.get('rounded')) ?? defaultEmbedParams.rounded, + border: convertBoolean(_searchParams.get('border')) ?? defaultEmbedParams.border, + autoload: convertBoolean(_searchParams.get('autoload')) ?? defaultEmbedParams.autoload, + header: convertBoolean(_searchParams.get('header')) ?? defaultEmbedParams.header, + }; +} diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend-shared/js/emoji-base.ts similarity index 87% rename from packages/frontend/src/scripts/emoji-base.ts rename to packages/frontend-shared/js/emoji-base.ts index a01540a3e44b..5fbbc4ea84c7 100644 --- a/packages/frontend/src/scripts/emoji-base.ts +++ b/packages/frontend-shared/js/emoji-base.ts @@ -19,7 +19,7 @@ export function char2fluentEmojiFilePath(char: string): string { // Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25 if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char); if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); - codes = codes.filter(x => x && x.length); - const fileName = codes.map(x => x!.padStart(4, '0')).join('-'); + codes = codes.filter(x => x != null && x.length > 0); + const fileName = (codes as string[]).map(x => x.padStart(4, '0')).join('-'); return `${fluentEmojiPngBase}/${fileName}.png`; } diff --git a/packages/frontend/src/emojilist.json b/packages/frontend-shared/js/emojilist.json similarity index 100% rename from packages/frontend/src/emojilist.json rename to packages/frontend-shared/js/emojilist.json diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend-shared/js/emojilist.ts similarity index 96% rename from packages/frontend/src/scripts/emojilist.ts rename to packages/frontend-shared/js/emojilist.ts index 6565feba97d2..bde30a864fd0 100644 --- a/packages/frontend/src/scripts/emojilist.ts +++ b/packages/frontend-shared/js/emojilist.ts @@ -12,12 +12,12 @@ export type UnicodeEmojiDef = { } // initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb -import _emojilist from '../emojilist.json'; +import _emojilist from './emojilist.json'; export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({ name: x[1] as string, char: x[0] as string, - category: unicodeEmojiCategories[x[2]], + category: unicodeEmojiCategories[x[2] as number], })); const unicodeEmojisMap = new Map( diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend-shared/js/extract-avg-color-from-blurhash.ts similarity index 100% rename from packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts rename to packages/frontend-shared/js/extract-avg-color-from-blurhash.ts diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend-shared/js/i18n.ts similarity index 78% rename from packages/frontend/src/scripts/i18n.ts rename to packages/frontend-shared/js/i18n.ts index c2f44a33cc47..18232691fa7e 100644 --- a/packages/frontend/src/scripts/i18n.ts +++ b/packages/frontend-shared/js/i18n.ts @@ -2,7 +2,10 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ILocale, ParameterizedString } from '../../../../locales/index.js'; +import type { ILocale, ParameterizedString } from '../../../locales/index.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TODO = any; type FlattenKeys = keyof { [K in keyof T as T[K] extends ILocale @@ -32,15 +35,18 @@ type Tsx = { export class I18n { private tsxCache?: Tsx; + private devMode: boolean; + + constructor(public locale: T, devMode = false) { + this.devMode = devMode; - constructor(public locale: T) { //#region BIND this.t = this.t.bind(this); //#endregion } public get ts(): T { - if (_DEV_) { + if (this.devMode) { class Handler implements ProxyHandler { get(target: TTarget, p: string | symbol): unknown { const value = target[p as keyof TTarget]; @@ -72,7 +78,7 @@ export class I18n { } public get tsx(): Tsx { - if (_DEV_) { + if (this.devMode) { if (this.tsxCache) { return this.tsxCache; } @@ -113,7 +119,7 @@ export class I18n { return () => value; } - return (arg) => { + return (arg: TODO) => { let str = quasis[0]; for (let i = 0; i < expressions.length; i++) { @@ -137,7 +143,6 @@ export class I18n { return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx; } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.tsxCache) { return this.tsxCache; } @@ -153,7 +158,7 @@ export class I18n { const value = target[k as keyof typeof target]; if (typeof value === 'object') { - result[k] = build(value as ILocale); + (result as TODO)[k] = build(value as ILocale); } else if (typeof value === 'string') { const quasis: string[] = []; const expressions: string[] = []; @@ -180,7 +185,7 @@ export class I18n { continue; } - result[k] = (arg) => { + (result as TODO)[k] = (arg: TODO) => { let str = quasis[0]; for (let i = 0; i < expressions.length; i++) { @@ -209,9 +214,9 @@ export class I18n { let str: string | ParameterizedString | ILocale = this.locale; for (const k of key.split('.')) { - str = str[k]; + str = (str as TODO)[k]; - if (_DEV_) { + if (this.devMode) { if (typeof str === 'undefined') { console.error(`Unexpected locale key: ${key}`); return key; @@ -220,7 +225,7 @@ export class I18n { } if (args) { - if (_DEV_) { + if (this.devMode) { const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter)); if (missing.length) { @@ -231,7 +236,7 @@ export class I18n { for (const [k, v] of Object.entries(args)) { const search = `{${k}}`; - if (_DEV_) { + if (this.devMode) { if (!(str as string).includes(search)) { console.error(`Unexpected locale parameter: ${k} at ${key}`); } @@ -244,51 +249,3 @@ export class I18n { return str; } } - -if (import.meta.vitest) { - const { describe, expect, it } = import.meta.vitest; - - describe('i18n', () => { - it('t', () => { - const i18n = new I18n({ - foo: 'foo', - bar: { - baz: 'baz', - qux: 'qux {0}' as unknown as ParameterizedString<'0'>, - quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, - }, - }); - - expect(i18n.t('foo')).toBe('foo'); - expect(i18n.t('bar.baz')).toBe('baz'); - expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); - expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); - }); - it('ts', () => { - const i18n = new I18n({ - foo: 'foo', - bar: { - baz: 'baz', - qux: 'qux {0}' as unknown as ParameterizedString<'0'>, - quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, - }, - }); - - expect(i18n.ts.foo).toBe('foo'); - expect(i18n.ts.bar.baz).toBe('baz'); - }); - it('tsx', () => { - const i18n = new I18n({ - foo: 'foo', - bar: { - baz: 'baz', - qux: 'qux {0}' as unknown as ParameterizedString<'0'>, - quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>, - }, - }); - - expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge'); - expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga'); - }); - }); -} diff --git a/packages/frontend-shared/js/intl-const.ts b/packages/frontend-shared/js/intl-const.ts new file mode 100644 index 000000000000..33b65b6e9b78 --- /dev/null +++ b/packages/frontend-shared/js/intl-const.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { lang } from '@@/js/config.js'; + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP'); + +let _dateTimeFormat: Intl.DateTimeFormat; +try { + _dateTimeFormat = new Intl.DateTimeFormat(versatileLang, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); +} catch (err) { + console.warn(err); + if (_DEV_) console.log('[Intl] Fallback to en-US'); + + // Fallback to en-US + _dateTimeFormat = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); +} +export const dateTimeFormat = _dateTimeFormat; + +export const timeZone = dateTimeFormat.resolvedOptions().timeZone; + +export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N'; + +let _numberFormat: Intl.NumberFormat; +try { + _numberFormat = new Intl.NumberFormat(versatileLang); +} catch (err) { + console.warn(err); + if (_DEV_) console.log('[Intl] Fallback to en-US'); + + // Fallback to en-US + _numberFormat = new Intl.NumberFormat('en-US'); +} +export const numberFormat = _numberFormat; diff --git a/packages/frontend-shared/js/is-link.ts b/packages/frontend-shared/js/is-link.ts new file mode 100644 index 000000000000..946f86400e16 --- /dev/null +++ b/packages/frontend-shared/js/is-link.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function isLink(el: HTMLElement) { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + return false; +} diff --git a/packages/frontend-shared/js/media-proxy.ts b/packages/frontend-shared/js/media-proxy.ts new file mode 100644 index 000000000000..2837870c9a63 --- /dev/null +++ b/packages/frontend-shared/js/media-proxy.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { query } from './url.js'; + +export class MediaProxy { + private serverMetadata: Misskey.entities.MetaDetailed; + private url: string; + + constructor(serverMetadata: Misskey.entities.MetaDetailed, url: string) { + this.serverMetadata = serverMetadata; + this.url = url; + } + + public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string { + const localProxy = `${this.url}/proxy`; + let _imageUrl = imageUrl; + + if (imageUrl.startsWith(this.serverMetadata.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) { + // もう既にproxyっぽそうだったらurlを取り出す + _imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl; + } + + return `${mustOrigin ? localProxy : this.serverMetadata.mediaProxy}/${ + type === 'preview' ? 'preview.webp' + : 'image.webp' + }?${query({ + url: _imageUrl, + ...(!noFallback ? { 'fallback': '1' } : {}), + ...(type ? { [type]: '1' } : {}), + ...(mustOrigin ? { origin: '1' } : {}), + })}`; + } + + public getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null { + if (imageUrl == null) return null; + return this.getProxiedImageUrl(imageUrl, type); + } + + public getStaticImageUrl(baseUrl: string): string { + const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, this.url); + + if (u.href.startsWith(`${this.url}/emoji/`)) { + // もう既にemojiっぽそうだったらsearchParams付けるだけ + u.searchParams.set('static', '1'); + return u.href; + } + + if (u.href.startsWith(this.serverMetadata.mediaProxy + '/')) { + // もう既にproxyっぽそうだったらsearchParams付けるだけ + u.searchParams.set('static', '1'); + return u.href; + } + + return `${this.serverMetadata.mediaProxy}/static.webp?${query({ + url: u.href, + static: '1', + })}`; + } +} diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend-shared/js/scroll.ts similarity index 82% rename from packages/frontend/src/scripts/scroll.ts rename to packages/frontend-shared/js/scroll.ts index f0274034b5b3..4f2e9105c340 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend-shared/js/scroll.ts @@ -36,19 +36,27 @@ export function getScrollPosition(el: HTMLElement | null): number { return container == null ? window.scrollY : container.scrollTop; } -export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) { +export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknown, tolerance = 1, once = false) { // とりあえず評価してみる - if (el.isConnected && isTopVisible(el)) { - cb(); + const firstTopVisible = isTopVisible(el); + if (el.isConnected && firstTopVisible) { + cb(firstTopVisible); if (once) return null; } const container = getScrollContainer(el) ?? window; - const onScroll = ev => { + // 以下のケースにおいて、cbが何度も呼び出されてしまって具合が悪いので1回呼んだら以降は無視するようにする + // - スクロールイベントは1回のスクロールで複数回発生することがある + // - toleranceの範囲内に収まる程度の微量なスクロールが発生した + let prevTopVisible = firstTopVisible; + const onScroll = () => { if (!document.body.contains(el)) return; - if (isTopVisible(el, tolerance)) { - cb(); + + const topVisible = isTopVisible(el, tolerance); + if (topVisible !== prevTopVisible) { + prevTopVisible = topVisible; + cb(topVisible); if (once) removeListener(); } }; @@ -69,7 +77,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 } const containerOrWindow = container ?? window; - const onScroll = ev => { + const onScroll = () => { if (!document.body.contains(el)) return; if (isBottomVisible(el, 1, container)) { cb(); @@ -126,6 +134,7 @@ export function scrollToBottom( export function isTopVisible(el: HTMLElement, tolerance = 1): boolean { const scrollTop = getScrollPosition(el); + if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance); return scrollTop <= tolerance; } diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend-shared/js/url.ts similarity index 70% rename from packages/frontend/src/scripts/url.ts rename to packages/frontend-shared/js/url.ts index 5a8265af9e10..eb830b1eeaec 100644 --- a/packages/frontend/src/scripts/url.ts +++ b/packages/frontend-shared/js/url.ts @@ -8,18 +8,18 @@ * 2. プロパティがundefinedの時はクエリを付けない * (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない) */ -export function query(obj: Record): string { +export function query(obj: Record): string { const params = Object.entries(obj) - .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) - .reduce((a, [k, v]) => (a[k] = v, a), {} as Record); + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) // eslint-disable-line @typescript-eslint/no-unnecessary-condition + .reduce>((a, [k, v]) => (a[k] = v, a), {}); return Object.entries(params) .map((p) => `${p[0]}=${encodeURIComponent(p[1])}`) .join('&'); } -export function appendQuery(url: string, query: string): string { - return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +export function appendQuery(url: string, queryString: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${queryString}`; } export function extractDomain(url: string) { diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend-shared/js/use-document-visibility.ts similarity index 85% rename from packages/frontend/src/scripts/use-document-visibility.ts rename to packages/frontend-shared/js/use-document-visibility.ts index a8f4d5e03ae2..b1197e68dae2 100644 --- a/packages/frontend/src/scripts/use-document-visibility.ts +++ b/packages/frontend-shared/js/use-document-visibility.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { onMounted, onUnmounted, ref, Ref } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; +import type { Ref } from 'vue'; export function useDocumentVisibility(): Ref { const visibility = ref(document.visibilityState); diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend-shared/js/use-interval.ts similarity index 100% rename from packages/frontend/src/scripts/use-interval.ts rename to packages/frontend-shared/js/use-interval.ts diff --git a/packages/frontend/src/scripts/worker-multi-dispatch.ts b/packages/frontend-shared/js/worker-multi-dispatch.ts similarity index 84% rename from packages/frontend/src/scripts/worker-multi-dispatch.ts rename to packages/frontend-shared/js/worker-multi-dispatch.ts index 6b3fcd938334..5d393ed1edef 100644 --- a/packages/frontend/src/scripts/worker-multi-dispatch.ts +++ b/packages/frontend-shared/js/worker-multi-dispatch.ts @@ -3,16 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -function defaultUseWorkerNumber(prev: number, totalWorkers: number) { +function defaultUseWorkerNumber(prev: number) { return prev + 1; } -export class WorkerMultiDispatch { +type WorkerNumberGetter = (prev: number, totalWorkers: number) => number; + +export class WorkerMultiDispatch { private symbol = Symbol('WorkerMultiDispatch'); private workers: Worker[] = []; private terminated = false; private prevWorkerNumber = 0; - private getUseWorkerNumber = defaultUseWorkerNumber; + private getUseWorkerNumber: WorkerNumberGetter; private finalizationRegistry: FinalizationRegistry; constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) { @@ -29,7 +31,7 @@ export class WorkerMultiDispatch { if (_DEV_) console.log('WorkerMultiDispatch: Created', this); } - public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) { + public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: WorkerNumberGetter = this.getUseWorkerNumber) { let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length); workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length; if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber); @@ -46,12 +48,14 @@ export class WorkerMultiDispatch { return workerNumber; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public addListener(callback: (this: Worker, ev: MessageEvent) => any, options?: boolean | AddEventListenerOptions) { this.workers.forEach(worker => { worker.addEventListener('message', callback, options); }); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public removeListener(callback: (this: Worker, ev: MessageEvent) => any, options?: boolean | AddEventListenerOptions) { this.workers.forEach(worker => { worker.removeEventListener('message', callback, options); diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json new file mode 100644 index 000000000000..1f397cd4b34e --- /dev/null +++ b/packages/frontend-shared/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend-shared", + "type": "module", + "main": "./js-built/index.js", + "types": "./js-built/index.d.ts", + "exports": { + ".": { + "import": "./js-built/index.js", + "types": "./js-built/index.d.ts" + }, + "./*": { + "import": "./js-built/*", + "types": "./js-built/*" + } + }, + "scripts": { + "build": "node ./build.js", + "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"", + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "typecheck": "tsc --noEmit", + "lint": "pnpm typecheck && pnpm eslint" + }, + "devDependencies": { + "@types/node": "20.14.12", + "@typescript-eslint/eslint-plugin": "8.7.0", + "@typescript-eslint/parser": "8.7.0", + "esbuild": "0.23.0", + "eslint-plugin-vue": "9.27.0", + "typescript": "5.5.4", + "vue-eslint-parser": "9.4.3" + }, + "files": [ + "js-built" + ], + "dependencies": { + "misskey-js": "workspace:*", + "vue": "3.4.37" + } +} diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 similarity index 89% rename from packages/frontend/src/themes/_dark.json5 rename to packages/frontend-shared/themes/_dark.json5 index c82a95686821..eb5fda3dc06f 100644 --- a/packages/frontend/src/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -13,6 +13,7 @@ accentDarken: ':darken<10<@accent', accentLighten: ':lighten<10<@accent', accentedBg: ':alpha<0.15<@accent', + love: '#dd2e44', focus: ':alpha<0.3<@accent', bg: '#000', acrylicBg: ':alpha<0.5<@bg', @@ -53,11 +54,13 @@ infoFg: '#fff', infoWarnBg: '#42321c', infoWarnFg: '#ffbd3e', - switchBg: 'rgba(255, 255, 255, 0.15)', - buttonBg: 'rgba(255, 255, 255, 0.05)', - buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + folderHeaderBg: 'rgba(255, 255, 255, 0.05)', + folderHeaderHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonBg: ':lighten<5<@panel', + buttonHoverBg: ':lighten<10<@panel', buttonGradateA: '@accent', buttonGradateB: ':hue<20<@accent', + switchBg: 'rgba(255, 255, 255, 0.15)', switchOffBg: 'rgba(255, 255, 255, 0.1)', switchOffFg: ':alpha<0.8<@fg', switchOnBg: '@accentedBg', @@ -77,22 +80,14 @@ codeBoolean: '#c59eff', deckBg: '#000', htmlThemeColor: '@bg', - X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', X4: 'rgba(255, 255, 255, 0.1)', X5: 'rgba(255, 255, 255, 0.05)', X6: 'rgba(255, 255, 255, 0.15)', X7: 'rgba(255, 255, 255, 0.05)', - X8: ':lighten<5<@accent', - X9: ':darken<5<@accent', - X10: ':alpha<0.4<@accent', X11: 'rgba(0, 0, 0, 0.3)', X12: 'rgba(255, 255, 255, 0.1)', X13: 'rgba(255, 255, 255, 0.15)', - X14: ':alpha<0.5<@navBg', - X15: ':alpha<0<@panel', - X16: ':alpha<0.7<@panel', - X17: ':alpha<0.8<@bg', }, codeHighlighter: { diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 similarity index 89% rename from packages/frontend/src/themes/_light.json5 rename to packages/frontend-shared/themes/_light.json5 index 63bc030916d8..e0196dcbf359 100644 --- a/packages/frontend/src/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -13,6 +13,7 @@ accentDarken: ':darken<10<@accent', accentLighten: ':lighten<10<@accent', accentedBg: ':alpha<0.15<@accent', + love: '#dd2e44', focus: ':alpha<0.3<@accent', bg: '#fff', acrylicBg: ':alpha<0.5<@bg', @@ -53,11 +54,13 @@ infoFg: '#72818a', infoWarnBg: '#fff0db', infoWarnFg: '#8f6e31', - switchBg: 'rgba(0, 0, 0, 0.15)', - buttonBg: 'rgba(0, 0, 0, 0.05)', - buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + folderHeaderBg: 'rgba(0, 0, 0, 0.05)', + folderHeaderHoverBg: 'rgba(0, 0, 0, 0.1)', + buttonBg: ':darken<5<@panel', + buttonHoverBg: ':darken<10<@panel', buttonGradateA: '@accent', buttonGradateB: ':hue<20<@accent', + switchBg: 'rgba(0, 0, 0, 0.15)', switchOffBg: 'rgba(0, 0, 0, 0.1)', switchOffFg: '@panel', switchOnBg: '@accent', @@ -77,22 +80,14 @@ codeBoolean: '#62b70c', deckBg: ':darken<3<@bg', htmlThemeColor: '@bg', - X2: ':darken<2<@panel', X3: 'rgba(0, 0, 0, 0.05)', X4: 'rgba(0, 0, 0, 0.1)', X5: 'rgba(0, 0, 0, 0.05)', X6: 'rgba(0, 0, 0, 0.25)', X7: 'rgba(0, 0, 0, 0.05)', - X8: ':lighten<5<@accent', - X9: ':darken<5<@accent', - X10: ':alpha<0.4<@accent', X11: 'rgba(0, 0, 0, 0.1)', X12: 'rgba(0, 0, 0, 0.1)', X13: 'rgba(0, 0, 0, 0.15)', - X14: ':alpha<0.5<@navBg', - X15: ':alpha<0<@panel', - X16: ':alpha<0.7<@panel', - X17: ':alpha<0.8<@bg', }, codeHighlighter: { diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5 similarity index 87% rename from packages/frontend/src/themes/d-astro.json5 rename to packages/frontend-shared/themes/d-astro.json5 index fee25cc4a420..a674a5c5c9ce 100644 --- a/packages/frontend/src/themes/d-astro.json5 +++ b/packages/frontend-shared/themes/d-astro.json5 @@ -25,7 +25,6 @@ mention: '#ffd152', modalBg: 'rgba(0, 0, 0, 0.5)', success: '#86b300', - buttonBg: 'rgba(255, 255, 255, 0.05)', acrylicBg: ':alpha<0.5<@bg', indicator: '@accent', mentionMe: '#fb5d38', @@ -42,7 +41,6 @@ acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@accent', accentLighten: ':lighten<10<@accent', - buttonHoverBg: 'rgba(255, 255, 255, 0.1)', buttonGradateA: '@accent', buttonGradateB: ':hue<-20<@accent', driveFolderBg: ':alpha<0.3<@accent', @@ -57,20 +55,13 @@ wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', panelHeaderDivider: 'rgba(0, 0, 0, 0)', scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', - X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', X4: 'rgba(255, 255, 255, 0.1)', X5: 'rgba(255, 255, 255, 0.05)', X6: 'rgba(255, 255, 255, 0.15)', X7: 'rgba(255, 255, 255, 0.05)', - X8: ':lighten<5<@accent', - X9: ':darken<5<@accent', - X10: ':alpha<0.4<@accent', X11: 'rgba(0, 0, 0, 0.3)', X12: 'rgba(255, 255, 255, 0.1)', X13: 'rgba(255, 255, 255, 0.15)', - X14: ':alpha<0.5<@navBg', - X15: ':alpha<0<@panel', - X16: ':alpha<0.7<@panel', }, } diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend-shared/themes/d-botanical.json5 similarity index 100% rename from packages/frontend/src/themes/d-botanical.json5 rename to packages/frontend-shared/themes/d-botanical.json5 diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend-shared/themes/d-cherry.json5 similarity index 100% rename from packages/frontend/src/themes/d-cherry.json5 rename to packages/frontend-shared/themes/d-cherry.json5 diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend-shared/themes/d-dark.json5 similarity index 100% rename from packages/frontend/src/themes/d-dark.json5 rename to packages/frontend-shared/themes/d-dark.json5 diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend-shared/themes/d-future.json5 similarity index 100% rename from packages/frontend/src/themes/d-future.json5 rename to packages/frontend-shared/themes/d-future.json5 diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend-shared/themes/d-green-lime.json5 similarity index 100% rename from packages/frontend/src/themes/d-green-lime.json5 rename to packages/frontend-shared/themes/d-green-lime.json5 diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend-shared/themes/d-green-orange.json5 similarity index 100% rename from packages/frontend/src/themes/d-green-orange.json5 rename to packages/frontend-shared/themes/d-green-orange.json5 diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend-shared/themes/d-ice.json5 similarity index 100% rename from packages/frontend/src/themes/d-ice.json5 rename to packages/frontend-shared/themes/d-ice.json5 diff --git a/packages/frontend-shared/themes/d-nekoplanet-dark.json5 b/packages/frontend-shared/themes/d-nekoplanet-dark.json5 new file mode 100644 index 000000000000..9398974ccb25 --- /dev/null +++ b/packages/frontend-shared/themes/d-nekoplanet-dark.json5 @@ -0,0 +1,92 @@ +{ + id: '4b0b3464-387f-4728-9b19-79df9db64fa4', + base: 'dark', + name: 'Nekoplanet Dark', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: '@X3', + X6: 'rgba(255, 255, 255, 0.15)', + X7: '@X3', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#24252b', + fg: '#a8aed4', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: '@X4', + X13: '@X6', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + accent: '#ffd220', + link: '@accent', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + deckBg: '#131314', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: '#ffe25d', + shadow: 'rgba(42, 43, 54, 0.5)', + divider: 'rgba(255, 211, 57, 0.45)', + hashtag: '#fd9618', + mention: '@accent', + modalBg: 'rgba(67, 78, 90, 0.5)', + success: '#ffd220', + buttonBg: 'rgba(200, 204, 221, 0.05)', + switchBg: 'rgba(200, 204, 221, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + fgOnWhite: '#5c5e73', + mentionMe: '#ffadad', + indicator: '@accent', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#464855', + infoWarnBg: '#5c5e73', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + switchOnBg: '@accentedBg', + switchOnFg: '@accent', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(183, 190, 206, 0.1)', + panelBorder: 'solid 1px var(--divider)', + switchOffBg: 'rgba(200, 204, 221, 0.1)', + switchOffFg: ':alpha<0.8<@fg', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + windowHeader: ':alpha<0.85<@panel', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(200, 204, 221, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: '#ff992c', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(183, 190, 206, 0.06)', + scrollbarHandle: 'rgba(255, 218, 76, 0.75)', + inputBorderHover: 'rgba(183, 190, 206, 0.5)', + wallpaperOverlay: 'rgba(147, 158, 195, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(255, 251, 225, 0.5)', + scrollbarHandleHover: 'rgba(255, 153, 44, 0.9)', + }, + author: 'Canon(Nekoplanet)', +} \ No newline at end of file diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend-shared/themes/d-persimmon.json5 similarity index 100% rename from packages/frontend/src/themes/d-persimmon.json5 rename to packages/frontend-shared/themes/d-persimmon.json5 diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 similarity index 93% rename from packages/frontend/src/themes/d-u0.json5 rename to packages/frontend-shared/themes/d-u0.json5 index 3bd0b9483c3d..32ac9ec5cfff 100644 --- a/packages/frontend/src/themes/d-u0.json5 +++ b/packages/frontend-shared/themes/d-u0.json5 @@ -3,14 +3,11 @@ base: 'dark', name: 'Mi U0 Dark', props: { - X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', X4: 'rgba(255, 255, 255, 0.1)', X5: 'rgba(255, 255, 255, 0.05)', X6: 'rgba(255, 255, 255, 0.15)', X7: 'rgba(255, 255, 255, 0.05)', - X8: ':lighten<5<@accent', - X9: ':darken<5<@accent', bg: '#172426', fg: '#dadada', X10: ':alpha<0.4<@accent', @@ -41,7 +38,6 @@ mention: '@accent', modalBg: 'rgba(0, 0, 0, 0.5)', success: '#86b300', - buttonBg: 'rgba(255, 255, 255, 0.05)', switchBg: 'rgba(255, 255, 255, 0.15)', acrylicBg: ':alpha<0.5<@bg', indicator: '@accent', @@ -64,7 +60,6 @@ acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@indicator', accentLighten: ':lighten<10<@accent', - buttonHoverBg: 'rgba(255, 255, 255, 0.1)', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', fgTransparent: ':alpha<0.5<@fg', diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend-shared/themes/l-apricot.json5 similarity index 100% rename from packages/frontend/src/themes/l-apricot.json5 rename to packages/frontend-shared/themes/l-apricot.json5 diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5 similarity index 100% rename from packages/frontend/src/themes/l-botanical.json5 rename to packages/frontend-shared/themes/l-botanical.json5 diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend-shared/themes/l-cherry.json5 similarity index 100% rename from packages/frontend/src/themes/l-cherry.json5 rename to packages/frontend-shared/themes/l-cherry.json5 diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend-shared/themes/l-coffee.json5 similarity index 100% rename from packages/frontend/src/themes/l-coffee.json5 rename to packages/frontend-shared/themes/l-coffee.json5 diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend-shared/themes/l-light.json5 similarity index 100% rename from packages/frontend/src/themes/l-light.json5 rename to packages/frontend-shared/themes/l-light.json5 diff --git a/packages/frontend-shared/themes/l-nekoplanet-light.json5 b/packages/frontend-shared/themes/l-nekoplanet-light.json5 new file mode 100644 index 000000000000..aadf2c66b6a3 --- /dev/null +++ b/packages/frontend-shared/themes/l-nekoplanet-light.json5 @@ -0,0 +1,92 @@ +{ + id: '8a0a6cd4-b807-4510-a755-754054c8a737', + base: 'light', + name: 'Nekoplanet Light', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: '@X3', + X6: 'rgba(255, 255, 255, 0.15)', + X7: '@X3', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#f4fbff', + fg: '#4d6079', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: '@X4', + X13: '@X6', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + link: '#3543a8', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: '#606df7', + deckBg: '#b7d9f1', + header: ':alpha<0.7<@panel', + infoBg: '#f2f3f5', + infoFg: '#394049', + renote: '#6092f7', + shadow: 'rgba(42, 43, 54, 0.5)', + divider: 'rgba(88, 161, 230, 0.7)', + hashtag: '#173875', + mention: '@accent', + modalBg: 'rgba(67, 78, 90, 0.1)', + success: '#606df7', + buttonBg: 'rgba(146, 149, 157, 0.2)', + switchBg: 'rgba(161, 187, 219, 0.2)', + acrylicBg: ':alpha<0.5<@bg', + fgOnWhite: '#5c5e73', + indicator: '@accent', + mentionMe: '#ff6d6d', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '@bg', + infoWarnBg: '#eff1f4', + infoWarnFg: '#5c5e73', + navHoverFg: ':lighten<17<@fg', + switchOnBg: '@accentedBg', + switchOnFg: '@accent', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(97, 128, 231, 0.75)', + panelBorder: 'solid 1px var(--divider)', + switchOffBg: 'rgba(161, 187, 219, 0.4)', + switchOffFg: ':alpha<0.8<@fg', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + windowHeader: ':alpha<0.85<@panel', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(200, 204, 221, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '#606df7', + buttonGradateB: '#433d96', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(183, 190, 206, 0.06)', + scrollbarHandle: 'rgba(115, 149, 248, 0.75)', + inputBorderHover: 'rgba(97, 128, 231, 0.9)', + wallpaperOverlay: 'rgba(147, 158, 195, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(50, 92, 146, 0.5)', + scrollbarHandleHover: 'rgba(74, 99, 224, 0.9)', + }, + author: 'Canon(Nekoplanet)', +} \ No newline at end of file diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend-shared/themes/l-rainy.json5 similarity index 100% rename from packages/frontend/src/themes/l-rainy.json5 rename to packages/frontend-shared/themes/l-rainy.json5 diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend-shared/themes/l-sushi.json5 similarity index 100% rename from packages/frontend/src/themes/l-sushi.json5 rename to packages/frontend-shared/themes/l-sushi.json5 diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5 similarity index 96% rename from packages/frontend/src/themes/l-u0.json5 rename to packages/frontend-shared/themes/l-u0.json5 index dbc777d49368..0b952b003ae5 100644 --- a/packages/frontend/src/themes/l-u0.json5 +++ b/packages/frontend-shared/themes/l-u0.json5 @@ -3,14 +3,11 @@ base: 'light', name: 'Mi U0 Light', props: { - X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', X4: 'rgba(255, 255, 255, 0.1)', X5: 'rgba(255, 255, 255, 0.05)', X6: 'rgba(255, 255, 255, 0.15)', X7: 'rgba(255, 255, 255, 0.05)', - X8: ':lighten<5<@accent', - X9: ':darken<5<@accent', bg: '#e7e7eb', fg: '#5f5f5f', X10: ':alpha<0.4<@accent', diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5 similarity index 86% rename from packages/frontend/src/themes/l-vivid.json5 rename to packages/frontend-shared/themes/l-vivid.json5 index 3368855b5e96..f1c63dde6eaf 100644 --- a/packages/frontend/src/themes/l-vivid.json5 +++ b/packages/frontend-shared/themes/l-vivid.json5 @@ -28,7 +28,6 @@ mention: '@accent', modalBg: 'rgba(0, 0, 0, 0.3)', success: '#86b300', - buttonBg: 'rgba(0, 0, 0, 0.05)', acrylicBg: ':alpha<0.5<@bg', indicator: '@accent', mentionMe: '@mention', @@ -45,7 +44,6 @@ acrylicPanel: ':alpha<0.5<@panel', navIndicator: '@accent', accentLighten: ':lighten<10<@accent', - buttonHoverBg: 'rgba(0, 0, 0, 0.1)', driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', fgTransparent: ':alpha<0.5<@fg', @@ -60,21 +58,13 @@ fgTransparentWeak: ':alpha<0.75<@fg', panelHeaderDivider: '@divider', scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', - X2: ':darken<2<@panel', X3: 'rgba(0, 0, 0, 0.05)', X4: 'rgba(0, 0, 0, 0.1)', X5: 'rgba(0, 0, 0, 0.05)', X6: 'rgba(0, 0, 0, 0.25)', X7: 'rgba(0, 0, 0, 0.05)', - X8: ':lighten<5<@accent', - X9: ':darken<5<@accent', - X10: ':alpha<0.4<@accent', X11: 'rgba(0, 0, 0, 0.1)', X12: 'rgba(0, 0, 0, 0.1)', X13: 'rgba(0, 0, 0, 0.15)', - X14: ':alpha<0.5<@navBg', - X15: ':alpha<0<@panel', - X16: ':alpha<0.7<@panel', - X17: ':alpha<0.8<@bg', }, } diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json new file mode 100644 index 000000000000..09a8ff76aa6e --- /dev/null +++ b/packages/frontend-shared/tsconfig.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "declaration": true, + "declarationMap": true, + "sourceMap": false, + "outDir": "./js-built/", + "removeComments": true, + "resolveJsonModule": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"], + "@@/*": ["./*"] + }, + "typeRoots": [ + "./@types", + "./node_modules/@types" + ], + "lib": [ + "esnext", + "dom" + ] + }, + "include": [ + "@types/**/*.ts", + "js/**/*" + ], + "exclude": [ + "node_modules", + "test/**/*" + ] +} diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index ab04d3e60c78..fc3b0334e470 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import type { entities } from 'misskey-js' export function abuseUserReport() { @@ -114,6 +115,40 @@ export function file(isSensitive = false) { }; } +const script = `/// @ ${AISCRIPT_VERSION} + +var name = "" + +Ui:render([ + Ui:C:textInput({ + label: "Your name" + onInput: @(v) { name = v } + }) + Ui:C:button({ + text: "Hello" + onClick: @() { + Mk:dialog(null, \`Hello, {name}!\`) + } + }) +]) +`; + +export function flash(): entities.Flash { + return { + id: 'someflashid', + createdAt: '2016-12-28T22:49:51.000Z', + updatedAt: '2016-12-28T22:49:51.000Z', + userId: 'someuserid', + user: userLite(), + title: 'Some Play title', + summary: 'Some Play summary', + script, + visibility: 'public', + likedCount: 0, + isLiked: false, + }; +} + export function folder(id = 'somefolderid', name = 'Some Folder', parentId: string | null = null): entities.DriveFolder { return { id, diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 52c01aaf702c..42d1a10f0ad0 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -398,14 +398,16 @@ function toStories(component: string): Promise { glob('src/components/global/Mk*.vue'), glob('src/components/global/RouterView.vue'), glob('src/components/Mk[A-E]*.vue'), + glob('src/components/MkFlashPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), glob('src/components/MkUserSetupDialog.*.vue'), glob('src/components/MkInstanceCardMini.vue'), glob('src/components/MkInviteCode.vue'), - glob('src/pages/search.vue'), + glob('src/pages/admin/overview.ap-requests.vue'), glob('src/pages/user/home.vue'), + glob('src/pages/search.vue'), ]); const components = globs.flat(); await Promise.all(components.map(async (component) => { diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts index fb93d7be1353..e5573f2ac3a0 100644 --- a/packages/frontend/.storybook/preload-theme.ts +++ b/packages/frontend/.storybook/preload-theme.ts @@ -30,7 +30,7 @@ const keys = [ 'd-u0', ] -await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => { +await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => { writeFile( new URL('./themes.ts', import.meta.url), `export default ${JSON.stringify( diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts index 0a7281898d98..70afc356c19d 100644 --- a/packages/frontend/@types/theme.d.ts +++ b/packages/frontend/@types/theme.d.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -declare module '@/themes/*.json5' { +declare module '@@/themes/*.json5' { import { Theme } from '@/scripts/theme.js'; const theme: Theme; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 244d117de231..3cc899862b7a 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -17,33 +17,32 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@discordapp/twemoji": "15.0.3", + "@discordapp/twemoji": "15.1.0", "@github/webauthn-json": "2.1.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "5.0.7", - "@rollup/pluginutils": "5.1.0", + "@rollup/pluginutils": "5.1.2", "@syuilo/aiscript": "0.19.0", "@tabler/icons-webfont": "3.3.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.1.0", - "@vue/compiler-sfc": "3.4.34", + "@vitejs/plugin-vue": "5.1.4", + "@vue/compiler-sfc": "3.5.10", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11", - "astring": "1.8.6", + "astring": "1.9.0", "broadcast-channel": "7.0.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", - "chart.js": "4.4.3", + "chart.js": "4.4.4", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "11.5.6", + "chromatic": "11.10.4", "compare-versions": "6.1.1", - "cropperjs": "2.0.0-rc.1", + "cropperjs": "2.0.0-rc.2", "date-fns": "2.30.0", - "escape-regexp": "0.0.1", "estree-walker": "3.0.3", "eventemitter3": "5.0.1", "idb-keyval": "6.2.1", @@ -55,87 +54,87 @@ "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", + "frontend-shared": "workspace:*", "photoswipe": "5.4.4", "punycode": "2.3.1", - "rollup": "4.19.1", + "rollup": "4.22.5", "sanitize-html": "2.13.0", - "sass": "1.77.8", + "sass": "1.79.3", "shiki": "1.12.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.167.0", + "three": "0.169.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", - "typescript": "5.5.4", + "typescript": "5.6.2", "uuid": "10.0.0", - "v-code-diff": "1.12.0", - "vite": "5.3.5", - "vue": "3.4.34", + "v-code-diff": "1.13.1", + "vite": "5.4.8", + "vue": "3.5.10", "vuedraggable": "next" }, "devDependencies": { "@misskey-dev/summaly": "5.1.0", - "@storybook/addon-actions": "8.2.6", - "@storybook/addon-essentials": "8.2.6", - "@storybook/addon-interactions": "8.2.6", - "@storybook/addon-links": "8.2.6", - "@storybook/addon-mdx-gfm": "8.2.6", - "@storybook/addon-storysource": "8.2.6", - "@storybook/blocks": "8.2.6", - "@storybook/components": "8.2.6", - "@storybook/core-events": "8.2.6", - "@storybook/manager-api": "8.2.6", - "@storybook/preview-api": "8.2.6", - "@storybook/react": "8.2.6", - "@storybook/react-vite": "8.2.6", - "@storybook/test": "8.2.6", - "@storybook/theming": "8.2.6", - "@storybook/types": "8.2.6", - "@storybook/vue3": "8.2.6", - "@storybook/vue3-vite": "8.1.11", + "@storybook/addon-actions": "8.3.3", + "@storybook/addon-essentials": "8.3.3", + "@storybook/addon-interactions": "8.3.3", + "@storybook/addon-links": "8.3.3", + "@storybook/addon-mdx-gfm": "8.3.3", + "@storybook/addon-storysource": "8.3.3", + "@storybook/blocks": "8.3.3", + "@storybook/components": "8.3.3", + "@storybook/core-events": "8.3.3", + "@storybook/manager-api": "8.3.3", + "@storybook/preview-api": "8.3.3", + "@storybook/react": "8.3.3", + "@storybook/react-vite": "8.3.3", + "@storybook/test": "8.3.3", + "@storybook/theming": "8.3.3", + "@storybook/types": "8.3.3", + "@storybook/vue3": "8.3.3", + "@storybook/vue3-vite": "8.3.3", "@testing-library/vue": "8.1.0", - "@types/escape-regexp": "0.0.3", - "@types/estree": "1.0.5", + "@types/estree": "1.0.6", "@types/matter-js": "0.19.7", "@types/micromatch": "4.0.9", "@types/node": "20.14.12", "@types/punycode": "2.1.4", - "@types/sanitize-html": "2.11.0", + "@types/sanitize-html": "2.13.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/uuid": "10.0.0", - "@types/ws": "8.5.11", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", + "@types/ws": "8.5.12", + "@typescript-eslint/eslint-plugin": "8.7.0", + "@typescript-eslint/parser": "8.7.0", "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.4.34", + "@vue/runtime-core": "3.5.10", "acorn": "8.12.1", "cross-env": "7.0.3", - "cypress": "13.13.1", - "eslint-plugin-import": "2.29.1", - "eslint-plugin-vue": "9.27.0", + "cypress": "13.15.0", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-vue": "9.28.0", "fast-glob": "3.3.2", "happy-dom": "10.0.3", "intersection-observer": "0.12.2", - "micromatch": "4.0.7", - "msw": "2.3.4", + "micromatch": "4.0.8", + "msw": "2.4.9", "msw-storybook-addon": "2.0.3", - "nodemon": "3.1.4", + "nodemon": "3.1.7", "prettier": "3.3.3", "react": "18.3.1", "react-dom": "18.3.1", "seedrandom": "3.0.5", - "start-server-and-test": "2.0.4", - "storybook": "8.2.6", - "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "start-server-and-test": "2.0.8", + "storybook": "8.3.3", + "storybook-addon-misskey-theme": "github:nekoplanet/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", "vitest": "1.6.0", "vitest-fetch-mock": "0.2.2", - "vue-component-type-helpers": "2.0.29", + "vue-component-type-helpers": "2.1.6", "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.0.29" + "vue-tsc": "2.1.6" } } diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 875353f8a482..13a97e433ca1 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -6,6 +6,8 @@ // https://vitejs.dev/config/build-options.html#build-modulepreload import 'vite/modulepreload-polyfill'; +import '@tabler/icons-webfont/dist/tabler-icons.scss'; + import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; diff --git a/packages/frontend/src/_dev_boot_.ts b/packages/frontend/src/_dev_boot_.ts index 7c6e537fbc17..1601f247d759 100644 --- a/packages/frontend/src/_dev_boot_.ts +++ b/packages/frontend/src/_dev_boot_.ts @@ -3,11 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// devモードで起動される際(index.htmlを使うとき)はrouterが暴発してしまってうまく読み込めない。 -// よって、devモードとして起動されるときはビルド時に組み込む形としておく。 -// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない) -import '@tabler/icons-webfont/dist/tabler-icons.scss'; - await main(); import('@/_boot_.js'); diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 4172016f8984..0c896f2f230f 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -8,9 +8,9 @@ import * as Misskey from 'misskey-js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; -import { MenuButton } from '@/types/menu.js'; +import type { MenuItem, MenuButton } from '@/types/menu.js'; import { del, get, set } from '@/scripts/idb-proxy.js'; -import { apiUrl } from '@/config.js'; +import { apiUrl } from '@@/js/config.js'; import { waiting, popup, popupMenu, success, alert } from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; @@ -288,36 +288,52 @@ export async function openAccountMenu(opts: { }); })); + const menuItems: MenuItem[] = []; + if (opts.withExtraOperation) { - popupMenu([...[{ - type: 'link' as const, + menuItems.push({ + type: 'link', text: i18n.ts.profile, - to: `/@${ $i.username }`, + to: `/@${$i.username}`, avatar: $i, - }, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent' as const, + }, { + type: 'divider', + }); + + if (opts.includeCurrentAccount) { + menuItems.push(createItem($i)); + } + + menuItems.push(...accountItemPromises); + + menuItems.push({ + type: 'parent', icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ text: i18n.ts.existingAccount, action: () => { showSigninDialog(); }, - }, { + }, /*{ text: i18n.ts.createAccount, action: () => { createAccount(); }, - }], + }*/], }, { - type: 'link' as const, + type: 'link', icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', - }]], ev.currentTarget ?? ev.target, { - align: 'left', }); } else { - popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { - align: 'left', - }); + if (opts.includeCurrentAccount) { + menuItems.push(createItem($i)); + } + + menuItems.push(...accountItemPromises); } + + popupMenu(menuItems, ev.currentTarget ?? ev.target, { + align: 'left', + }); } if (_DEV_) { diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index d86ae18ffeac..af05f657c858 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,10 +5,10 @@ import { computed, watch, version as vueVersion, App } from 'vue'; import { compareVersions } from 'compare-versions'; +import { version, lang, updateLocale, locale } from '@@/js/config.js'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; -import { version, lang, updateLocale, locale } from '@/config.js'; import { applyTheme } from '@/scripts/theme.js'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; import { updateI18n } from '@/i18n.js'; @@ -22,7 +22,8 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; -import { setupRouter } from '@/router/definition.js'; +import { setupRouter } from '@/router/main.js'; +import { createMainRouter } from '@/router/definition.js'; export async function common(createVue: () => App) { console.info(`Misskey v${version}`); @@ -145,10 +146,9 @@ export async function common(createVue: () => App) { // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) watch(defaultStore.reactiveState.darkMode, (darkMode) => { applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); - document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light'; }, { immediate: miLocalStorage.getItem('theme') == null }); - document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light'; + document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light'; const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); @@ -239,7 +239,7 @@ export async function common(createVue: () => App) { const app = createVue(); - setupRouter(app); + setupRouter(app, createMainRouter); if (_DEV_) { app.config.performance = true; diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 2a549d1e8bc9..ddd47ca448b8 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -6,7 +6,7 @@ import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { common } from './common.js'; import type * as Misskey from 'misskey-js'; -import { ui } from '@/config.js'; +import { ui } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; @@ -22,6 +22,7 @@ import { deckStore } from '@/ui/deck/deck-store.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mainRouter } from '@/router/main.js'; import { type Keymap, makeHotkey } from '@/scripts/hotkey.js'; +import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( @@ -62,6 +63,18 @@ export async function mainBoot() { } }); + stream.on('emojiAdded', emojiData => { + addCustomEmoji(emojiData.emoji); + }); + + stream.on('emojiUpdated', emojiData => { + updateCustomEmojis(emojiData.emojis); + }); + + stream.on('emojiDeleted', emojiData => { + removeCustomEmojis(emojiData.emojis); + }); + for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { import('@/plugin.js').then(async ({ install }) => { // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 @@ -231,17 +244,18 @@ export async function mainBoot() { claimAchievement('client60min'); }, 1000 * 60 * 60); - const lastUsed = miLocalStorage.getItem('lastUsed'); - if (lastUsed) { - const lastUsedDate = parseInt(lastUsed, 10); - // 二時間以上前なら - if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { - toast(i18n.tsx.welcomeBackWithName({ - name: $i.name || $i.username, - })); - } - } - miLocalStorage.setItem('lastUsed', Date.now().toString()); + // 邪魔 + //const lastUsed = miLocalStorage.getItem('lastUsed'); + //if (lastUsed) { + // const lastUsedDate = parseInt(lastUsed, 10); + // // 二時間以上前なら + // if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { + // toast(i18n.tsx.welcomeBackWithName({ + // name: $i.name || $i.username, + // })); + // } + //} + //miLocalStorage.setItem('lastUsed', Date.now().toString()); const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index b09c7bb3fb5d..a634a748e936 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -39,7 +39,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - user: Misskey.entities.UserDetailed; + user: Misskey.entities.UserLite; initialComment?: string; }>(); diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 6c0774b63426..796524fce949 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -16,7 +16,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMention from './MkMention.vue'; import { i18n } from '@/i18n.js'; -import { host as localHost } from '@/config.js'; +import { host as localHost } from '@@/js/config.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; const user = ref(); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 18e8e7542e3f..b50a7fea5c47 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
@@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index c13164c2968d..fca7aa2f4ec1 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -91,6 +91,12 @@ const props = defineProps<{ } } + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + @media (max-width: 700px) { } diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index f805be7b5726..a5f3069d4515 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -41,6 +41,9 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ +
@@ -136,7 +139,7 @@ onMounted(() => { width: 100%; box-sizing: border-box; padding: 9px 12px 9px 12px; - background: var(--buttonBg); + background: var(--folderHeaderBg); -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); border-radius: 6px; @@ -144,7 +147,7 @@ onMounted(() => { &:hover { text-decoration: none; - background: var(--buttonHoverBg); + background: var(--folderHeaderHoverBg); } &:focus-within { @@ -153,7 +156,7 @@ onMounted(() => { &.active { color: var(--accent); - background: var(--buttonHoverBg); + background: var(--folderHeaderHoverBg); } &.opened { @@ -224,4 +227,18 @@ onMounted(() => { background: var(--bg); } } + +.footer { + position: sticky !important; + z-index: 1; + bottom: var(--stickyBottom, 0px); + left: 0; + padding: 12px; + background: var(--acrylicBg); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, var(--panel) 5px, var(--panel) 10px); + border-radius: 0 0 6px 6px; +} diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index d8ac8024b477..0de52906ed28 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.processing }} diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index a32893368688..9e6cd04365aa 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -88,14 +88,13 @@ SPDX-License-Identifier: AGPL-3.0-only - - - -
- {{ i18n.ts.flagAsCat }} - {{ i18n.ts.flagAsBot }} -
-
+ + + + @@ -105,6 +104,15 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ {{ i18n.ts.flagAsCat }} + {{ i18n.ts.flagAsBot }} +
+
@@ -138,6 +146,7 @@ const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAccep const profile = reactive({ name: $i.name, description: $i.description, + followedMessage: $i.followedMessage, location: $i.location, birthday: $i.birthday, lang: $i.lang, @@ -185,6 +194,8 @@ function save() { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing description: profile.description || null, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + followedMessage: profile.followedMessage || null, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing location: profile.location || null, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing birthday: profile.birthday || null, diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 7d192bcbea6e..ce8ec686928a 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -88,19 +88,9 @@ import { uniqueBy } from '@/scripts/array.js'; import { fetchThemes, getThemes } from '@/theme-store.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { miLocalStorage } from '@/local-storage.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { reloadAsk } from '@/scripts/reload-ask.js'; import * as os from '@/os.js'; -async function reloadAsk() { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); @@ -148,13 +138,13 @@ watch(syncDeviceDarkMode, () => { } }); -watch(wallpaper, () => { +watch(wallpaper, async () => { if (wallpaper.value == null) { miLocalStorage.removeItem('wallpaper'); } else { miLocalStorage.setItem('wallpaper', wallpaper.value); } - reloadAsk(); + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); onActivated(() => { diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 058ef69c35e3..adeaf8550cd3 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -21,14 +21,41 @@ SPDX-License-Identifier: AGPL-3.0-only -
- {{ i18n.ts._webhookSettings._events.follow }} - {{ i18n.ts._webhookSettings._events.followed }} - {{ i18n.ts._webhookSettings._events.note }} - {{ i18n.ts._webhookSettings._events.reply }} - {{ i18n.ts._webhookSettings._events.renote }} - {{ i18n.ts._webhookSettings._events.reaction }} - {{ i18n.ts._webhookSettings._events.mention }} +
+
+
+ {{ i18n.ts._webhookSettings._events.follow }} + +
+
+ {{ i18n.ts._webhookSettings._events.followed }} + +
+
+ {{ i18n.ts._webhookSettings._events.note }} + +
+
+ {{ i18n.ts._webhookSettings._events.reply }} + +
+
+ {{ i18n.ts._webhookSettings._events.renote }} + +
+
+ {{ i18n.ts._webhookSettings._events.reaction }} + +
+
+ {{ i18n.ts._webhookSettings._events.mention }} + +
+
+ +
+ {{ i18n.ts._webhookSettings.testRemarks }} +
@@ -43,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index 9b77392872fb..1b3e1ecaee67 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -28,6 +28,7 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; +import { genEmbedCode } from '@/scripts/get-embed-code.js'; const props = defineProps<{ tag: string; @@ -51,7 +52,19 @@ async function post() { notes.value?.pagingComponent?.reload(); } -const headerActions = computed(() => []); +const headerActions = computed(() => [{ + icon: 'ti ti-dots', + label: i18n.ts.more, + handler: (ev: MouseEvent) => { + os.popupMenu([{ + text: i18n.ts.genEmbedCode, + icon: 'ti ti-code', + action: () => { + genEmbedCode('tags', props.tag); + }, + }], ev.currentTarget ?? ev.target); + } +}]); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 50c3beeabc3b..a62fe5d581d0 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -79,6 +79,8 @@ import tinycolor from 'tinycolor2'; import { v4 as uuid } from 'uuid'; import JSON5 from 'json5'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -86,9 +88,7 @@ import MkFolder from '@/components/MkFolder.vue'; import { $i } from '@/account.js'; import { Theme, applyTheme } from '@/scripts/theme.js'; -import lightTheme from '@/themes/_light.json5'; -import darkTheme from '@/themes/_dark.json5'; -import { host } from '@/config.js'; +import { host } from '@@/js/config.js'; import * as os from '@/os.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; import { addTheme } from '@/theme-store.js'; diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 32f6dd0e5a6c..12e2db2293e6 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -34,13 +34,13 @@ SPDX-License-Identifier: AGPL-3.0-only `, + ]; + return iframeCode.join('\n'); +} + +/** + * 埋め込みコードを生成してコピーする(カスタマイズ機能つき) + * + * カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください + */ +export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) { + const _params = { ...params }; + + if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) { + _params.maxHeight = 700; + } + + // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー + if (window.innerWidth < MOBILE_THRESHOLD) { + copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params)); + os.success(); + } else { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), { + entity, + id, + params: _params, + }, { + closed: () => dispose(), + }); + } +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index ebb96d1746bb..d3ce6ce3d70f 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -5,21 +5,23 @@ import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; import { claimAchievement } from './achievements.js'; +import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { url } from '@/config.js'; import { defaultStore, noteActions } from '@/store.js'; import { miLocalStorage } from '@/local-storage.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import { clipsCache, favoritedChannelsCache } from '@/cache.js'; -import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { isSupportShare } from '@/scripts/navigator.js'; +import { getAppearNote } from '@/scripts/get-appear-note.js'; +import { genEmbedCode } from '@/scripts/get-embed-code.js'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; @@ -34,14 +36,7 @@ export async function getNoteClipMenu(props: { } } - const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds.length === 0 && - props.note.poll == null - ); - - const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + const appearNote = getAppearNote(props.note); const clips = await clipsCache.fetch(); const menu: MenuItem[] = [...clips.map(clip => ({ @@ -72,6 +67,11 @@ export async function getNoteClipMenu(props: { }); if (props.currentClip?.id === clip.id) props.isDeleted.value = true; } + } else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') { + os.alert({ + type: 'error', + text: i18n.ts.clipNoteLimitExceeded, + }); } else { os.alert({ type: 'error', @@ -99,11 +99,13 @@ export async function getNoteClipMenu(props: { const { canceled, result } = await os.form(i18n.ts.createNewClip, { name: { type: 'string', + default: null, label: i18n.ts.name, }, description: { type: 'string', required: false, + default: null, multiline: true, label: i18n.ts.description, }, @@ -157,6 +159,19 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): }; } +function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined { + if (note.url != null || note.uri != null) return undefined; + if (['specified', 'followers'].includes(note.visibility)) return undefined; + + return { + icon: 'ti ti-code', + text, + action: (): void => { + genEmbedCode('notes', note.id); + }, + }; +} + export function getNoteMenu(props: { note: Misskey.entities.Note; translation: Ref; @@ -164,17 +179,14 @@ export function getNoteMenu(props: { isDeleted: Ref; currentClip?: Misskey.entities.Clip; }) { - const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds.length === 0 && - props.note.poll == null - ); - - const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + const appearNote = getAppearNote(props.note); const cleanups = [] as (() => void)[]; + function edit(): void { + os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel, updateMode: true }); + } + function del(): void { os.confirm({ type: 'warning', @@ -237,7 +249,7 @@ export function getNoteMenu(props: { function togglePin(pin: boolean): void { os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { noteId: appearNote.id, - }, undefined, null, res => { + }).then((res: (Partial | Misskey.entities.Error['error'])) => { if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { os.alert({ type: 'error', @@ -248,6 +260,7 @@ export function getNoteMenu(props: { } async function unclip(): Promise { + if (!props.currentClip) return; os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); props.isDeleted.value = true; } @@ -257,7 +270,7 @@ export function getNoteMenu(props: { title: i18n.ts.numberOfDays, }); - if (canceled) return; + if (canceled || days == null) return; os.apiWithDialog('admin/promo/create', { noteId: appearNote.id, @@ -267,8 +280,8 @@ export function getNoteMenu(props: { function share(): void { navigator.share({ - title: i18n.tsx.noteOf({ user: appearNote.user.name }), - text: appearNote.text, + title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }), + text: appearNote.text ?? '', url: `${url}/notes/${appearNote.id}`, }); } @@ -288,161 +301,182 @@ export function getNoteMenu(props: { props.translation.value = res; } - let menu: MenuItem[]; + const menuItems: MenuItem[] = []; + if ($i) { const statePromise = misskeyApi('notes/state', { noteId: appearNote.id, }); - menu = [ - ...( - props.currentClip?.userId === $i.id ? [{ - icon: 'ti ti-backspace', - text: i18n.ts.unclip, - danger: true, - action: unclip, - }, { type: 'divider' }] : [] - ), { - icon: 'ti ti-info-circle', - text: i18n.ts.details, - action: openDetail, - }, { - icon: 'ti ti-copy', - text: i18n.ts.copyContent, - action: copyContent, - }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink) - , (appearNote.url || appearNote.uri) ? { + if (props.currentClip?.userId === $i.id) { + menuItems.push({ + icon: 'ti ti-backspace', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, { type: 'divider' }); + } + + menuItems.push({ + icon: 'ti ti-info-circle', + text: i18n.ts.details, + action: openDetail, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)); + + if (appearNote.url || appearNote.uri) { + menuItems.push({ icon: 'ti ti-external-link', text: i18n.ts.showOnRemote, action: () => { window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); }, - } : undefined, - ...(isSupportShare() ? [{ + }); + } else { + menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + } + + if (isSupportShare()) { + menuItems.push({ icon: 'ti ti-share', text: i18n.ts.share, action: share, - }] : []), - $i && $i.policies.canUseTranslator && instance.translatorAvailable ? { + }); + } + + if ($i.policies.canUseTranslator && instance.translatorAvailable) { + menuItems.push({ icon: 'ti ti-language-hiragana', text: i18n.ts.translate, action: translate, - } : undefined, - { type: 'divider' }, - statePromise.then(state => state.isFavorited ? { - icon: 'ti ti-star-off', - text: i18n.ts.unfavorite, - action: () => toggleFavorite(false), - } : { - icon: 'ti ti-star', - text: i18n.ts.favorite, - action: () => toggleFavorite(true), - }), - { - type: 'parent' as const, - icon: 'ti ti-paperclip', - text: i18n.ts.clip, - children: () => getNoteClipMenu(props), + }); + } + + menuItems.push({ type: 'divider' }); + + menuItems.push(statePromise.then(state => state.isFavorited ? { + icon: 'ti ti-star-off', + text: i18n.ts.unfavorite, + action: () => toggleFavorite(false), + } : { + icon: 'ti ti-star', + text: i18n.ts.favorite, + action: () => toggleFavorite(true), + })); + + menuItems.push({ + type: 'parent', + icon: 'ti ti-paperclip', + text: i18n.ts.clip, + children: () => getNoteClipMenu(props), + }); + + menuItems.push(statePromise.then(state => state.isMutedThread ? { + icon: 'ti ti-message-off', + text: i18n.ts.unmuteThread, + action: () => toggleThreadMute(false), + } : { + icon: 'ti ti-message-off', + text: i18n.ts.muteThread, + action: () => toggleThreadMute(true), + })); + + if (appearNote.userId === $i.id) { + if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) { + menuItems.push({ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => togglePin(false), + }); + } else { + menuItems.push({ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => togglePin(true), + }); + } + } + + menuItems.push({ + type: 'parent', + icon: 'ti ti-user', + text: i18n.ts.user, + children: async () => { + const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId }); + const { menu, cleanup } = getUserMenu(user); + cleanups.push(cleanup); + return menu; }, - statePromise.then(state => state.isMutedThread ? { - icon: 'ti ti-message-off', - text: i18n.ts.unmuteThread, - action: () => toggleThreadMute(false), - } : { - icon: 'ti ti-message-off', - text: i18n.ts.muteThread, - action: () => toggleThreadMute(true), - }), - appearNote.userId === $i.id ? ($i.pinnedNoteIds ?? []).includes(appearNote.id) ? { - icon: 'ti ti-pinned-off', - text: i18n.ts.unpin, - action: () => togglePin(false), - } : { - icon: 'ti ti-pin', - text: i18n.ts.pin, - action: () => togglePin(true), - } : undefined, - { - type: 'parent' as const, - icon: 'ti ti-user', - text: i18n.ts.user, + }); + + if (appearNote.userId !== $i.id) { + menuItems.push({ type: 'divider' }); + menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse)); + } + + if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) { + menuItems.push({ type: 'divider' }); + menuItems.push({ + type: 'parent', + icon: 'ti ti-device-tv', + text: i18n.ts.channel, children: async () => { - const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId }); - const { menu, cleanup } = getUserMenu(user); - cleanups.push(cleanup); - return menu; - }, - }, - /* - ...($i.isModerator || $i.isAdmin ? [ - { type: 'divider' }, - { - icon: 'ti ti-speakerphone', - text: i18n.ts.promote, - action: promote - }] - : [] - ),*/ - ...(appearNote.userId !== $i.id ? [ - { type: 'divider' }, - appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined, - ] - : [] - ), - ...(appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin) ? [ - { type: 'divider' }, - { - type: 'parent' as const, - icon: 'ti ti-device-tv', - text: i18n.ts.channel, - children: async () => { - const channelChildMenu = [] as MenuItem[]; - - const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id }); - - if (channel.pinnedNoteIds.includes(appearNote.id)) { - channelChildMenu.push({ - icon: 'ti ti-pinned-off', - text: i18n.ts.unpin, - action: () => os.apiWithDialog('channels/update', { - channelId: appearNote.channel!.id, - pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id), - }), - }); - } else { - channelChildMenu.push({ - icon: 'ti ti-pin', - text: i18n.ts.pin, - action: () => os.apiWithDialog('channels/update', { - channelId: appearNote.channel!.id, - pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id], - }), - }); - } - return channelChildMenu; - }, + const channelChildMenu = [] as MenuItem[]; + + const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id }); + + if (channel.pinnedNoteIds.includes(appearNote.id)) { + channelChildMenu.push({ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => os.apiWithDialog('channels/update', { + channelId: appearNote.channel!.id, + pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id), + }), + }); + } else { + channelChildMenu.push({ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => os.apiWithDialog('channels/update', { + channelId: appearNote.channel!.id, + pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id], + }), + }); + } + return channelChildMenu; }, - ] - : [] - ), - ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ - { type: 'divider' }, - appearNote.userId === $i.id ? { + }); + } + + if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) { + menuItems.push({ type: 'divider' }); + if (appearNote.userId === $i.id) { + menuItems.push({ icon: 'ti ti-edit', text: i18n.ts.deleteAndEdit, action: delEdit, - } : undefined, - { - icon: 'ti ti-trash', - text: i18n.ts.delete, - danger: true, - action: del, - }] - : [] - )] - .filter(x => x !== undefined); + }); + } + if (appearNote.userId === $i.id && $i.policies.canEditNote) { + menuItems.push({ + icon: 'ti ti-edit', + text: i18n.ts.edit, + action: edit, + }); + } + menuItems.push({ + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: del, + }); + } } else { - menu = [{ + menuItems.push({ icon: 'ti ti-info-circle', text: i18n.ts.details, action: openDetail, @@ -450,35 +484,42 @@ export function getNoteMenu(props: { icon: 'ti ti-copy', text: i18n.ts.copyContent, action: copyContent, - }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink) - , (appearNote.url || appearNote.uri) ? { - icon: 'ti ti-external-link', - text: i18n.ts.showOnRemote, - action: () => { - window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); - }, - } : undefined] - .filter(x => x !== undefined); + }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)); + + if (appearNote.url || appearNote.uri) { + menuItems.push({ + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); + }, + }); + } else { + menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + } } if (noteActions.length > 0) { - menu = menu.concat([{ type: 'divider' }, ...noteActions.map(action => ({ + menuItems.push({ type: 'divider' }); + + menuItems.push(...noteActions.map(action => ({ icon: 'ti ti-plug', text: action.title, action: () => { action.handler(appearNote); }, - }))]); + }))); } if (defaultStore.state.devMode) { - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-id', text: i18n.ts.copyNoteId, action: () => { copyToClipboard(appearNote.id); + os.success(); }, - }]); + }); } const cleanup = () => { @@ -489,7 +530,7 @@ export function getNoteMenu(props: { }; return { - menu, + menu: menuItems, cleanup, }; } @@ -509,14 +550,7 @@ export function getRenoteMenu(props: { renoteButton: ShallowRef; mock?: boolean; }) { - const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds.length === 0 && - props.note.poll == null - ); - - const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + const appearNote = getAppearNote(props.note); const channelRenoteItems: MenuItem[] = []; const normalRenoteItems: MenuItem[] = []; diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 33f16a68aa61..d15279d63339 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -8,7 +8,7 @@ import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { host, url } from '@/config.js'; +import { host, url } from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore, userActions } from '@/store.js'; @@ -17,7 +17,8 @@ import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-pe import { IRouter } from '@/nirax.js'; import { antennasCache, rolesCache, userListsCache } from '@/cache.js'; import { mainRouter } from '@/router/main.js'; -import { MenuItem } from '@/types/menu.js'; +import { genEmbedCode } from '@/scripts/get-embed-code.js'; +import type { MenuItem } from '@/types/menu.js'; export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { const meId = $i ? $i.id : null; @@ -147,123 +148,154 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - let menu: MenuItem[] = [{ + const menuItems: MenuItem[] = []; + + menuItems.push({ icon: 'ti ti-at', text: i18n.ts.copyUsername, action: () => { copyToClipboard(`@${user.username}@${user.host ?? host}`); }, - }, ...( notesSearchAvailable && (user.host == null || canSearchNonLocalNotes) ? [{ - icon: 'ti ti-search', - text: i18n.ts.searchThisUsersNotes, - action: () => { - router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); - }, - }] : []) - , ...(iAmModerator ? [{ - icon: 'ti ti-user-exclamation', - text: i18n.ts.moderation, - action: () => { - router.push(`/admin/user/${user.id}`); - }, - }] : []), { + }); + + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { + menuItems.push({ + icon: 'ti ti-search', + text: i18n.ts.searchThisUsersNotes, + action: () => { + router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); + }, + }); + } + + if (iAmModerator) { + menuItems.push({ + icon: 'ti ti-user-exclamation', + text: i18n.ts.moderation, + action: () => { + router.push(`/admin/user/${user.id}`); + }, + }); + } + + menuItems.push({ icon: 'ti ti-rss', text: i18n.ts.copyRSS, action: () => { copyToClipboard(`${user.host ?? host}/@${user.username}.atom`); }, - }, ...(user.host != null && user.url != null ? [{ - icon: 'ti ti-external-link', - text: i18n.ts.showOnRemote, - action: () => { - if (user.url == null) return; - window.open(user.url, '_blank', 'noopener'); - }, - }] : []), { + }); + + if (user.host != null && user.url != null) { + menuItems.push({ + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + if (user.url == null) return; + window.open(user.url, '_blank', 'noopener'); + }, + }); + } else { + menuItems.push({ + icon: 'ti ti-code', + text: i18n.ts.genEmbedCode, + type: 'parent', + children: [{ + text: i18n.ts.noteOfThisUser, + action: () => { + genEmbedCode('user-timeline', user.id); + }, + }], // TODO: ユーザーカードの埋め込みなど + }); + } + + menuItems.push({ icon: 'ti ti-share', text: i18n.ts.copyProfileUrl, action: () => { const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; copyToClipboard(`${url}/${canonical}`); }, - }, ...($i ? [{ - icon: 'ti ti-mail', - text: i18n.ts.sendMessage, - action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; - os.post({ specified: user, initialText: `${canonical} ` }); - }, - }, { type: 'divider' }, { - icon: 'ti ti-pencil', - text: i18n.ts.editMemo, - action: () => { - editMemo(); - }, - }, { - type: 'parent', - icon: 'ti ti-list', - text: i18n.ts.addToList, - children: async () => { - const lists = await userListsCache.fetch(); - return lists.map(list => { - const isListed = ref(list.userIds.includes(user.id)); - cleanups.push(watch(isListed, () => { - if (isListed.value) { - os.apiWithDialog('users/lists/push', { - listId: list.id, - userId: user.id, - }).then(() => { - list.userIds.push(user.id); - }); - } else { - os.apiWithDialog('users/lists/pull', { - listId: list.id, - userId: user.id, - }).then(() => { - list.userIds.splice(list.userIds.indexOf(user.id), 1); + }); + + if ($i) { + menuItems.push({ + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; + os.post({ specified: user, initialText: `${canonical} ` }); + }, + }, { type: 'divider' }, { + icon: 'ti ti-pencil', + text: i18n.ts.editMemo, + action: editMemo, + }, { + type: 'parent', + icon: 'ti ti-list', + text: i18n.ts.addToList, + children: async () => { + const lists = await userListsCache.fetch(); + return lists.map(list => { + const isListed = ref(list.userIds?.includes(user.id) ?? false); + cleanups.push(watch(isListed, () => { + if (isListed.value) { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }).then(() => { + list.userIds?.push(user.id); + }); + } else { + os.apiWithDialog('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + list.userIds?.splice(list.userIds?.indexOf(user.id), 1); + }); + } + })); + + return { + type: 'switch', + text: list.name, + ref: isListed, + }; + }); + }, + }, { + type: 'parent', + icon: 'ti ti-antenna', + text: i18n.ts.addToAntenna, + children: async () => { + const antennas = await antennasCache.fetch(); + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + return antennas.filter((a) => a.src === 'users').map(antenna => ({ + text: antenna.name, + action: async () => { + await os.apiWithDialog('antennas/update', { + antennaId: antenna.id, + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + users: [...antenna.users, canonical], + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, }); - } + antennasCache.delete(); + }, })); - - return { - type: 'switch', - text: list.name, - ref: isListed, - }; - }); - }, - }, { - type: 'parent', - icon: 'ti ti-antenna', - text: i18n.ts.addToAntenna, - children: async () => { - const antennas = await antennasCache.fetch(); - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; - return antennas.filter((a) => a.src === 'users').map(antenna => ({ - text: antenna.name, - action: async () => { - await os.apiWithDialog('antennas/update', { - antennaId: antenna.id, - name: antenna.name, - keywords: antenna.keywords, - excludeKeywords: antenna.excludeKeywords, - src: antenna.src, - userListId: antenna.userListId, - users: [...antenna.users, canonical], - caseSensitive: antenna.caseSensitive, - withReplies: antenna.withReplies, - withFile: antenna.withFile, - notify: antenna.notify, - }); - antennasCache.delete(); - }, - })); - }, - }] : [])] as any; + }, + }); + } if ($i && meId !== user.id) { if (iAmModerator) { - menu = menu.concat([{ + menuItems.push({ type: 'parent', icon: 'ti ti-badges', text: i18n.ts.roles, @@ -301,13 +333,14 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }, })); }, - }]); + }); } // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため //if (user.isFollowing) { - const withRepliesRef = ref(user.withReplies); - menu = menu.concat([{ + const withRepliesRef = ref(user.withReplies ?? false); + + menuItems.push({ type: 'switch', icon: 'ti ti-messages', text: i18n.ts.showRepliesToOthersInTimeline, @@ -316,7 +349,8 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off', text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, action: toggleNotify, - }]); + }); + watch(withRepliesRef, (withReplies) => { misskeyApi('following/update', { userId: user.id, @@ -327,7 +361,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); //} - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }, { icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, @@ -339,70 +373,68 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter icon: 'ti ti-ban', text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, action: toggleBlock, - }]); + }); if (user.isFollowed) { - menu = menu.concat([{ + menuItems.push({ icon: 'ti ti-link-off', text: i18n.ts.breakFollow, action: invalidateFollow, - }]); + }); } - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-exclamation-circle', text: i18n.ts.reportAbuse, action: reportAbuse, - }]); + }); } if (user.host !== null) { - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-refresh', text: i18n.ts.updateRemoteUser, action: userInfoUpdate, - }]); + }); } if (defaultStore.state.devMode) { - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-id', text: i18n.ts.copyUserId, action: () => { copyToClipboard(user.id); }, - }]); + }); } if ($i && meId === user.id) { - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }, { icon: 'ti ti-pencil', text: i18n.ts.editProfile, action: () => { router.push('/settings/profile'); }, - }]); + }); } if (userActions.length > 0) { - menu = menu.concat([{ type: 'divider' }, ...userActions.map(action => ({ + menuItems.push({ type: 'divider' }, ...userActions.map(action => ({ icon: 'ti ti-plug', text: action.title, action: () => { action.handler(user); }, - }))]); + }))); } - const cleanup = () => { - if (_DEV_) console.log('user menu cleanup', cleanups); - for (const cl of cleanups) { - cl(); - } - }; - return { - menu, - cleanup, + menu: menuItems, + cleanup: () => { + if (_DEV_) console.log('user menu cleanup', cleanups); + for (const cl of cleanups) { + cl(); + } + }, }; } diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts index 6b511f2a5fc7..20f51660c725 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -10,10 +10,11 @@ import { set as iset, del as idel, } from 'idb-keyval'; +import { miLocalStorage } from '@/local-storage.js'; -const fallbackName = (key: string) => `idbfallback::${key}`; +const PREFIX = 'idbfallback::'; -let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true; +let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true; // iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。 // バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと @@ -38,15 +39,15 @@ if (idbAvailable) { export async function get(key: string) { if (idbAvailable) return iget(key); - return JSON.parse(window.localStorage.getItem(fallbackName(key))); + return miLocalStorage.getItemAsJson(`${PREFIX}${key}`); } export async function set(key: string, val: any) { if (idbAvailable) return iset(key, val); - return window.localStorage.setItem(fallbackName(key), JSON.stringify(val)); + return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val); } export async function del(key: string) { if (idbAvailable) return idel(key); - return window.localStorage.removeItem(fallbackName(key)); + return miLocalStorage.removeItem(`${PREFIX}${key}`); } diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts index 1517e4e1e8fa..867ebf19edad 100644 --- a/packages/frontend/src/scripts/initialize-sw.ts +++ b/packages/frontend/src/scripts/initialize-sw.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { lang } from '@/config.js'; +import { lang } from '@@/js/config.js'; export async function initializeSw() { if (!('serviceWorker' in navigator)) return; diff --git a/packages/frontend/src/scripts/intl-const.ts b/packages/frontend/src/scripts/intl-const.ts index aaa4f0a86edf..385f59ec39c0 100644 --- a/packages/frontend/src/scripts/intl-const.ts +++ b/packages/frontend/src/scripts/intl-const.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { lang } from '@/config.js'; +import { lang } from '@@/js/config.js'; export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP'); diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts index 406404c4622c..e28e5725bc91 100644 --- a/packages/frontend/src/scripts/isFfVisibleForMe.ts +++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts @@ -7,7 +7,7 @@ import * as Misskey from 'misskey-js'; import { $i } from '@/account.js'; export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean { - if ($i && $i.id === user.id) return true; + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; if (user.followingVisibility === 'private') return false; if (user.followingVisibility === 'followers' && !user.isFollowing) return false; @@ -15,7 +15,7 @@ export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): bo return true; } export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean { - if ($i && $i.id === user.id) return true; + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; if (user.followersVisibility === 'private') return false; if (user.followersVisibility === 'followers' && !user.isFollowing) return false; diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 099a22163af4..78eba35eaddb 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -3,51 +3,32 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { query } from '@/scripts/url.js'; -import { url } from '@/config.js'; +import { MediaProxy } from '@@/js/media-proxy.js'; +import { url } from '@@/js/config.js'; import { instance } from '@/instance.js'; -export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string { - const localProxy = `${url}/proxy`; +let _mediaProxy: MediaProxy | null = null; - if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) { - // もう既にproxyっぽそうだったらurlを取り出す - imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl; +export function getProxiedImageUrl(...args: Parameters): string { + if (_mediaProxy == null) { + _mediaProxy = new MediaProxy(instance, url); } - return `${mustOrigin ? localProxy : instance.mediaProxy}/${ - type === 'preview' ? 'preview.webp' - : 'image.webp' - }?${query({ - url: imageUrl, - ...(!noFallback ? { 'fallback': '1' } : {}), - ...(type ? { [type]: '1' } : {}), - ...(mustOrigin ? { origin: '1' } : {}), - })}`; + return _mediaProxy.getProxiedImageUrl(...args); } -export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null { - if (imageUrl == null) return null; - return getProxiedImageUrl(imageUrl, type); -} - -export function getStaticImageUrl(baseUrl: string): string { - const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, url); - - if (u.href.startsWith(`${url}/emoji/`)) { - // もう既にemojiっぽそうだったらsearchParams付けるだけ - u.searchParams.set('static', '1'); - return u.href; +export function getProxiedImageUrlNullable(...args: Parameters): string | null { + if (_mediaProxy == null) { + _mediaProxy = new MediaProxy(instance, url); } - if (u.href.startsWith(instance.mediaProxy + '/')) { - // もう既にproxyっぽそうだったらsearchParams付けるだけ - u.searchParams.set('static', '1'); - return u.href; + return _mediaProxy.getProxiedImageUrlNullable(...args); +} + +export function getStaticImageUrl(...args: Parameters): string { + if (_mediaProxy == null) { + _mediaProxy = new MediaProxy(instance, url); } - return `${instance.mediaProxy}/static.webp?${query({ - url: u.href, - static: '1', - })}`; + return _mediaProxy.getStaticImageUrl(...args); } diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts index 9938e534c139..bf59fe98a0ed 100644 --- a/packages/frontend/src/scripts/mfm-function-picker.ts +++ b/packages/frontend/src/scripts/mfm-function-picker.ts @@ -6,7 +6,7 @@ import { Ref, nextTick } from 'vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { MFM_TAGS } from '@/const.js'; +import { MFM_TAGS } from '@@/js/const.js'; import type { MenuItem } from '@/types/menu.js'; /** diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index 49fb6f9e59c6..1b1159fd015a 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -5,7 +5,7 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; -import { apiUrl } from '@/config.js'; +import { apiUrl } from '@@/js/config.js'; import { $i } from '@/account.js'; export const pendingApiRequestsCount = ref(0); diff --git a/packages/frontend/src/scripts/player-url-transform.ts b/packages/frontend/src/scripts/player-url-transform.ts index 53b2a9e44151..39c6df65004a 100644 --- a/packages/frontend/src/scripts/player-url-transform.ts +++ b/packages/frontend/src/scripts/player-url-transform.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { hostname } from '@/config.js'; +import { hostname } from '@@/js/config.js'; export function transformPlayerUrl(url: string): string { const urlObj = new URL(url); diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts index 1caa2dfc2101..5b141222e84f 100644 --- a/packages/frontend/src/scripts/popout.ts +++ b/packages/frontend/src/scripts/popout.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { appendQuery } from './url.js'; -import * as config from '@/config.js'; +import { appendQuery } from '@@/js/url.js'; +import * as config from '@@/js/config.js'; export function popout(path: string, w?: HTMLElement) { let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts index 31a9ac1ad9dc..11b6f52ddd0d 100644 --- a/packages/frontend/src/scripts/post-message.ts +++ b/packages/frontend/src/scripts/post-message.ts @@ -18,7 +18,7 @@ export type MiPostMessageEvent = { * 親フレームにイベントを送信 */ export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void { - window.postMessage({ + window.parent.postMessage({ type, payload, }, '*'); diff --git a/packages/frontend/src/scripts/reload-ask.ts b/packages/frontend/src/scripts/reload-ask.ts new file mode 100644 index 000000000000..733d91b85ab4 --- /dev/null +++ b/packages/frontend/src/scripts/reload-ask.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { unisonReload } from '@/scripts/unison-reload.js'; + +let isReloadConfirming = false; + +export async function reloadAsk(opts: { + unison?: boolean; + reason?: string; +}) { + if (isReloadConfirming) { + return; + } + + isReloadConfirming = true; + + const { canceled } = await os.confirm(opts.reason == null ? { + type: 'info', + text: i18n.ts.reloadConfirm, + } : { + type: 'info', + title: i18n.ts.reloadConfirm, + text: opts.reason, + }).finally(() => { + isReloadConfirming = false; + }); + + if (canceled) return; + + if (opts.unison) { + unisonReload(); + } else { + location.reload(); + } +} diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts deleted file mode 100644 index 6bfcef6c362c..000000000000 --- a/packages/frontend/src/scripts/safe-parse.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function safeParseFloat(str: unknown): number | null { - if (typeof str !== 'string' || str === '') return null; - const num = parseFloat(str); - if (isNaN(num)) return null; - return num; -} diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts deleted file mode 100644 index 0edf4e9eba0f..000000000000 --- a/packages/frontend/src/scripts/safe-uri-decode.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function safeURIDecode(str: string): string { - try { - return decodeURIComponent(str); - } catch { - return str; - } -} diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/scripts/stream-mock.ts new file mode 100644 index 000000000000..cb0e607fcb6a --- /dev/null +++ b/packages/frontend/src/scripts/stream-mock.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import * as Misskey from 'misskey-js'; +import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js'; + +type AnyOf> = T[keyof T]; +type OmitFirst = T extends [any, ...infer R] ? R : never; + +/** + * Websocket無効化時に使うStreamのモック(なにもしない) + */ +export class StreamMock extends EventEmitter implements IStream { + public readonly state = 'initializing'; + + constructor(...args: ConstructorParameters) { + super(); + // do nothing + } + + public useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock { + return new ChannelConnectionMock(this, channel, name); + } + + public removeSharedConnection(connection: any): void { + // do nothing + } + + public removeSharedConnectionPool(pool: any): void { + // do nothing + } + + public disconnectToChannel(): void { + // do nothing + } + + public send(typeOrPayload: string): void + public send(typeOrPayload: string, payload: any): void + public send(typeOrPayload: Record | any[]): void + public send(typeOrPayload: string | Record | any[], payload?: any): void { + // do nothing + } + + public ping(): void { + // do nothing + } + + public heartbeat(): void { + // do nothing + } + + public close(): void { + // do nothing + } +} + +class ChannelConnectionMock = any> extends EventEmitter implements IChannelConnection { + public id = ''; + public name?: string; // for debug + public inCount = 0; // for debug + public outCount = 0; // for debug + public channel: string; + + constructor(stream: IStream, ...args: OmitFirst>>) { + super(); + + this.channel = args[0]; + this.name = args[1]; + } + + public send(type: T, body: Channel['receives'][T]): void { + // do nothing + } + + public dispose(): void { + // do nothing + } +} diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts index c7f8b3d59663..b7e7a5a3f8ab 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/scripts/theme.ts @@ -5,11 +5,11 @@ import { ref } from 'vue'; import tinycolor from 'tinycolor2'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; import { deepClone } from './clone.js'; import type { BundledTheme } from 'shiki/themes'; import { globalEvents } from '@/events.js'; -import lightTheme from '@/themes/_light.json5'; -import darkTheme from '@/themes/_dark.json5'; import { miLocalStorage } from '@/local-storage.js'; export type Theme = { @@ -52,7 +52,7 @@ export const getBuiltinThemes = () => Promise.all( 'd-cherry', 'd-ice', 'd-u0', - ].map(name => import(`@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)), + ].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)), ); export const getBuiltinThemesRef = () => { @@ -74,6 +74,8 @@ export function applyTheme(theme: Theme, persist = true) { const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; + document.documentElement.dataset.colorScheme = colorScheme; + // Deep copy const _theme = deepClone(theme); diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts index 3e947183c9ea..22dce609c692 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/scripts/upload.ts @@ -9,10 +9,11 @@ import { v4 as uuid } from 'uuid'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { getCompressionConfig } from './upload/compress-config.js'; import { defaultStore } from '@/store.js'; -import { apiUrl } from '@/config.js'; +import { apiUrl } from '@@/js/config.js'; import { $i } from '@/account.js'; import { alert } from '@/os.js'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; type Uploading = { id: string; @@ -39,6 +40,15 @@ export function uploadFile( if (folder && typeof folder === 'object') folder = folder.id; + if (file.size > instance.maxFileSize) { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + return Promise.reject(); + } + return new Promise((resolve, reject) => { const id = uuid(); diff --git a/packages/frontend/src/scripts/use-form.ts b/packages/frontend/src/scripts/use-form.ts new file mode 100644 index 000000000000..0d505fe4668b --- /dev/null +++ b/packages/frontend/src/scripts/use-form.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, Reactive, reactive, watch } from 'vue'; + +function copy(v: T): T { + return JSON.parse(JSON.stringify(v)); +} + +function unwrapReactive(v: Reactive): T { + return JSON.parse(JSON.stringify(v)); +} + +export function useForm>(initialState: T, save: (newState: T) => Promise) { + const currentState = reactive(copy(initialState)); + const previousState = reactive(copy(initialState)); + + const modifiedStates = reactive>({} as any); + for (const key in currentState) { + modifiedStates[key] = false; + } + const modified = computed(() => Object.values(modifiedStates).some(v => v)); + const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length); + + watch([currentState, previousState], () => { + for (const key in modifiedStates) { + modifiedStates[key] = currentState[key] !== previousState[key]; + } + }, { deep: true }); + + async function _save() { + await save(unwrapReactive(currentState)); + for (const key in currentState) { + previousState[key] = copy(currentState[key]); + } + } + + function discard() { + for (const key in currentState) { + currentState[key] = copy(previousState[key]); + } + } + + return { + state: currentState, + savedState: previousState, + modifiedStates, + modified, + modifiedCount, + save: _save, + discard, + }; +} diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts index 542d8ab52b18..d58922931c6e 100644 --- a/packages/frontend/src/scripts/use-note-capture.ts +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -75,6 +75,16 @@ export function useNoteCapture(props: { break; } + case 'updated': { + note.value.updatedAt = new Date().toISOString(); + note.value.cw = body.cw; + note.value.text = body.text; + note.value.files = body.files; + note.value.fileIds = body.fileIds; + note.value.poll = body.poll; + break; + } + case 'deleted': { props.isDeletedRef.value = true; break; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 437314074a0c..1ddcca5afecb 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -5,10 +5,12 @@ import { markRaw, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { hemisphere } from '@@/js/intl-const.js'; +import lightTheme from '@@/themes/l-light.json5'; +import darkTheme from '@@/themes/d-green-lime.json5'; import { miLocalStorage } from './local-storage.js'; import type { SoundType } from '@/scripts/sound.js'; import { Storage } from '@/pizzax.js'; -import { hemisphere } from '@/scripts/intl-const.js'; interface PostFormAction { title: string, @@ -250,9 +252,9 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'twemoji', // twemoji / fluentEmoji / native }, - disableDrawer: { + menuStyle: { where: 'device', - default: false, + default: 'auto' as 'auto' | 'popup' | 'drawer', }, useBlurEffectForModal: { where: 'device', @@ -302,9 +304,9 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 2, }, - emojiPickerUseDrawerForMobile: { + emojiPickerStyle: { where: 'device', - default: true, + default: 'auto' as 'auto' | 'popup' | 'drawer', }, recentlyUsedEmojis: { where: 'device', @@ -390,9 +392,9 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'horizontal' as 'vertical' | 'horizontal', }, - enableCondensedLineForAcct: { + enableCondensedLine: { where: 'device', - default: false, + default: true, }, additionalUnicodeEmojiIndexes: { where: 'device', @@ -458,10 +460,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - contextMenu: { + contextMenu: { where: 'device', default: 'app' as 'app' | 'appWithShift' | 'native', - }, + }, sound_masterVolume: { where: 'device', @@ -520,8 +522,6 @@ interface Watcher { /** * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) */ -import lightTheme from '@/themes/l-light.json5'; -import darkTheme from '@/themes/d-green-lime.json5'; export class ColdDeviceStorage { public static default = { @@ -558,7 +558,7 @@ export class ColdDeviceStorage { public static set(key: T, value: typeof ColdDeviceStorage.default[T]): void { // 呼び出し側のバグ等で undefined が来ることがある // undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value === undefined) { console.error(`attempt to store undefined value for key '${key}'`); return; diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index 0d5bd78b09af..e63dac951c00 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -6,18 +6,21 @@ import * as Misskey from 'misskey-js'; import { markRaw } from 'vue'; import { $i } from '@/account.js'; -import { wsOrigin } from '@/config.js'; +import { wsOrigin } from '@@/js/config.js'; +// TODO: No WebsocketモードでStreamMockが使えそう +//import { StreamMock } from '@/scripts/stream-mock.js'; // heart beat interval in ms const HEART_BEAT_INTERVAL = 1000 * 60; -let stream: Misskey.Stream | null = null; -let timeoutHeartBeat: ReturnType | null = null; +let stream: Misskey.IStream | null = null; +let timeoutHeartBeat: number | null = null; let lastHeartbeatCall = 0; -export function useStream(): Misskey.Stream { +export function useStream(): Misskey.IStream { if (stream) return stream; + // TODO: No Websocketモードもここで判定 stream = markRaw(new Misskey.Stream(wsOrigin, $i ? { token: $i.token, } : null)); diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 2feb79ef810f..09fc8041c9a9 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -6,6 +6,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +@import url('https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@400&display=swap'); + +@font-face { + // Override + font-family: 'M PLUS Rounded 1c'; + src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_2304-01@1.0/omyu_pretty.woff2') format('woff2'); + font-weight: normal; + font-style: normal; + unicode-range: U+20-7E, U+3130-318E, U+AC00-D7A3, U+4E00-9FAF; +} + :root { --radius: 12px; --marginFull: 16px; @@ -17,18 +28,20 @@ --minBottomSpacingMobile: calc(72px + max(12px, env(safe-area-inset-bottom, 0px))); --minBottomSpacing: var(--minBottomSpacingMobile); - @media (max-width: 500px) { - --margin: var(--marginHalf); + & { + //--ad: rgb(255 169 0 / 10%); + --eventFollow: #36aed2; + --eventRenote: #36d298; + --eventReply: #007aff; + --eventReactionHeart: #dd2e44; + --eventReaction: #e99a0b; + --eventAchievement: #cb9a11; + --eventOther: #88a6b7; + + @media (max-width: 500px) { + --margin: var(--marginHalf); + } } - - //--ad: rgb(255 169 0 / 10%); - --eventFollow: #36aed2; - --eventRenote: #36d298; - --eventReply: #007aff; - --eventReactionHeart: #dd2e44; - --eventReaction: #e99a0b; - --eventAchievement: #cb9a11; - --eventOther: #88a6b7; } ::selection { @@ -42,7 +55,11 @@ html { accent-color: var(--accent); overflow: auto; overflow-wrap: break-word; - font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-family: + 'M PLUS Rounded 1c', // custom (neko) + 'Hiragino Maru Gothic Pro', "BIZ UDGothic", // jp fallback (upstream) + Roboto, HelveticaNeue, Arial, // html fallback (upstream) + sans-serif; // defaults to font-size: 14px; line-height: 1.35; text-size-adjust: 100%; @@ -255,11 +272,11 @@ rt { background: var(--accent); &:not(:disabled):hover { - background: var(--X8); + background: hsl(from var(--accent) h s calc(l + 5)); } &:not(:disabled):active { - background: var(--X9); + background: hsl(from var(--accent) h s calc(l - 5)); } } @@ -269,11 +286,11 @@ rt { background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, var(--X8), var(--X8)); + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, var(--X8), var(--X8)); + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); } } @@ -378,6 +395,16 @@ rt { vertical-align: top; } +._modified { + margin-left: 0.7em; + font-size: 65%; + padding: 2px 3px; + color: var(--warn); + border: solid 1px var(--warn); + border-radius: 4px; + vertical-align: top; +} + ._table { > ._row { display: flex; @@ -447,7 +474,7 @@ rt { --fg: #693410; } -html[data-color-mode=dark] ._woodenFrame { +html[data-color-scheme=dark] ._woodenFrame { --bg: #1d0c02; --fg: #F1E8DC; --panel: #192320; diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 74c30287450f..f908803f0116 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -7,7 +7,7 @@ import { defineAsyncComponent } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import { host } from '@/config.js'; +import { host } from '@@/js/config.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; @@ -41,7 +41,9 @@ function toolsMenuItems(): MenuItem[] { } export function openInstanceMenu(ev: MouseEvent) { - os.popupMenu([{ + const menuItems: MenuItem[] = []; + + menuItems.push({ text: instance.name ?? host, type: 'label', }, { @@ -69,12 +71,18 @@ export function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.ads, icon: 'ti ti-ad', to: '/ads', - }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? { - type: 'link', - to: '/invite', - text: i18n.ts.invite, - icon: 'ti ti-user-plus', - } : undefined, { + }); + + if ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) { + menuItems.push({ + type: 'link', + to: '/invite', + text: i18n.ts.invite, + icon: 'ti ti-user-plus', + }); + } + + menuItems.push({ type: 'parent', text: i18n.ts.tools, icon: 'ti ti-tool', @@ -84,43 +92,69 @@ export function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.inquiry, icon: 'ti ti-help-circle', to: '/contact', - }, (instance.impressumUrl) ? { - type: 'a', - text: i18n.ts.impressum, - icon: 'ti ti-file-invoice', - href: instance.impressumUrl, - target: '_blank', - } : undefined, (instance.tosUrl) ? { - type: 'a', - text: i18n.ts.termsOfService, - icon: 'ti ti-notebook', - href: instance.tosUrl, - target: '_blank', - } : undefined, (instance.privacyPolicyUrl) ? { - type: 'a', - text: i18n.ts.privacyPolicy, - icon: 'ti ti-shield-lock', - href: instance.privacyPolicyUrl, - target: '_blank', - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { + }); + + if (instance.impressumUrl) { + menuItems.push({ + type: 'a', + text: i18n.ts.impressum, + icon: 'ti ti-file-invoice', + href: instance.impressumUrl, + target: '_blank', + }); + } + + if (instance.tosUrl) { + menuItems.push({ + type: 'a', + text: i18n.ts.termsOfService, + icon: 'ti ti-notebook', + href: instance.tosUrl, + target: '_blank', + }); + } + + if (instance.privacyPolicyUrl) { + menuItems.push({ + type: 'a', + text: i18n.ts.privacyPolicy, + icon: 'ti ti-shield-lock', + href: instance.privacyPolicyUrl, + target: '_blank', + }); + } + + if (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) { + menuItems.push({ type: 'divider' }); + } + + menuItems.push({ type: 'a', text: i18n.ts.document, icon: 'ti ti-bulb', href: 'https://misskey-hub.net/docs/for-users/', target: '_blank', - }, ($i) ? { - text: i18n.ts._initialTutorial.launchTutorial, - icon: 'ti ti-presentation', - action: () => { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, { - closed: () => dispose(), - }); - }, - } : undefined, { + }); + + if ($i) { + menuItems.push({ + text: i18n.ts._initialTutorial.launchTutorial, + icon: 'ti ti-presentation', + action: () => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, { + closed: () => dispose(), + }); + }, + }); + } + + menuItems.push({ type: 'link', text: i18n.ts.aboutMisskey, to: '/about-misskey', - }], ev.currentTarget ?? ev.target, { + }); + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target, { align: 'left', }); } diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index d7df2d10f9e9..61881c02e186 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -39,9 +39,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-
DEV BUILD
+
DEV BUILD
-
{{ i18n.ts.loggedInAsBot }}
+
{{ i18n.ts.loggedInAsBot }}