diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml new file mode 100644 index 0000000000..c076a4aa57 --- /dev/null +++ b/.config/cypress-devcontainer.yml @@ -0,0 +1,211 @@ +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# CherryPick configuration +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# ┌─────┐ +#───┘ URL └───────────────────────────────────────────────────── + +# Final accessible URL seen by a user. +url: 'http://cherrypick.local' + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# URL SETTINGS AFTER THAT! + +# ┌───────────────────────┐ +#───┘ Port and TLS settings └─────────────────────────────────── + +# +# CherryPick requires a reverse proxy to support HTTPS connections. +# +# +-------- https://example.tld/ ------------+ +# +------+ |+-------------+ +-------------------+| +# | User | ---> || Proxy (443) | ---> | CherryPick (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 CherryPick server should listen on. +port: 61812 + +# ┌──────────────────────────┐ +#───┘ PostgreSQL configuration └──────────────────────────────── + +db: + host: db + port: 5432 + + # Database name + db: cherrypick + + # 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 d9e257c8af..18553237a0 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 235f4a3f67..2487bce6bc 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 3c2128bae3..d35cdab0d0 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 55fb1e6fa6..e02a533c15 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/workflows/api-cherrypick-js.yml b/.github/workflows/api-cherrypick-js.yml index 6a2fb9f1c1..a9569a7b04 100644 --- a/.github/workflows/api-cherrypick-js.yml +++ b/.github/workflows/api-cherrypick-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 ffe14a753a..60f25cc290 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-cherrypick-js-autogen.yml b/.github/workflows/check-cherrypick-js-autogen.yml index 0cdb201b36..7083430406 100644 --- a/.github/workflows/check-cherrypick-js-autogen.yml +++ b/.github/workflows/check-cherrypick-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 6cd8bf60d5..05582008b5 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 6599d1dfd6..f5af6a6bfc 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 ef42291d17..559e1f952d 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/cherrypick-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/cherrypick-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 - cherrypick-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 - cherrypick-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 cherrypick-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 95251bfe31..6bc8860a11 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 b6bb62df12..01b5d1ea24 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/report-api-diff.yml b/.github/workflows/report-api-diff.yml index 7ec2aae3ce..10c4f5b562 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 차이 -
- 차이점은 여기에서 볼 수 있음 + 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')" - \`\`\`diff - $(cat ./api.json.diff) - \`\`\` -
+ echo "$HEADER" > ./output.md - [Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) - EOF + 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 index b96347f536..e79524ef5a 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -41,7 +41,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js 20.x - 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/test-backend.yml b/.github/workflows/test-backend.yml index 5b53bfa0bd..4ef5c6d822 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-cherrypick-js.yml b/.github/workflows/test-cherrypick-js.yml index 510910eb18..98039a44ce 100644 --- a/.github/workflows/test-cherrypick-js.yml +++ b/.github/workflows/test-cherrypick-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-frontend.yml b/.github/workflows/test-frontend.yml index c7cc36f03e..5f630f1528 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-production.yml b/.github/workflows/test-production.yml index bea84cee35..9315d19fe3 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 06e987f27e..f809af1063 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 f5650bf0ae..662127e78a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ coverage !/.config/example.yml !/.config/docker_example.yml !/.config/docker_example.env +!/.config/cypress-devcontainer.yml docker-compose.yml compose.yml .devcontainer/compose.yml @@ -44,6 +45,7 @@ compose.yml /build built built-test +js-built /data /.cache-loader /db @@ -63,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/CHANGELOG.md b/CHANGELOG.md index 30108d3621..e6e2a9215a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ +## Unreleased + +### General +- + +### Client +- Enhance: フォロワーへのメッセージ欄のデザイン改良 + +### 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 diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index 0ad0575958..b6105e353e 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -23,6 +23,96 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE # 릴리즈 노트 이 문서는 CherryPick의 변경 사항만 포함합니다. +## 4.12.0 +출시일: 2024/10/08
+기반 Misskey 버전: 2024.9.0
+Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#202490](CHANGELOG.md#202490) 문서를 참고하십시오. + +## NOTE +- Enable condensed line의 기본값이 꺼짐으로 변경됨 + - 활성화하면 글자를 표시하기 위한 여유 공간이 좁을 때 디자인이 상대적으로 어색하게 보일 수 있으며, 실험실 기능이므로 이 기능이 변경하는 부분을 확실히 알고 있는 사용자만 활성화할 것을 권장합니다. +- 비밀번호 해싱 알고리즘이 `bcrypt`에서 `argon2`로 변경됨 + - 이 변경으로 이후에 비밀번호를 변경하거나 신규로 가입한 사용자는 `argon2`를 사용하여 비밀번호 해시가 생성됩니다. + - 이전에 가입한 사용자는 로그인 시 자동으로 `bcypt`에서 `argon2`로 해시가 변경됩니다. + +### General +- Feat: 위젯 영역을 숨길 수 있음 + - 기존 Friendly UI 한정 기능이 다른 UI에서도 사용할 수 있도록 확대됨 +- Feat: `내용 숨기기`로 설정한 내용을 항상 보이게 설정할 수 있음 (kokonect-link/cherrypick#495) +- Feat: 내용이 긴 노트의 간략화 여부를 선택할 수 있음 (kokonect-link/cherrypick#495) +- Feat: 답글로 작성된 노트를 간략화하여 표시할 수 있음 (kokonect-link/cherrypick#495) + - 리액션한 노트는 옵션 활성화 유무와 상관없이 항상 표시됩니다. +- Feat: 사용자 메뉴에서 원격 서버를 관리할 수 있음 (kokonect-link/cherrypick#502) + - `서버 차단`, `서버 사일런스`, `서버 미디어 사일런스` +- Feat: 노트 동작 버튼을 개인화할 수 있음 (kokonect-link/cherrypick#501) +- Feat: 답글 대상 노트의 반투명 옵션을 선택할 수 있음 (kokonect-link/cherrypick#495) +- Feat: 사용자 페이지의 미디어 탭을 그리드 레이아웃으로 설정할 수 있음 (kokonect-link/cherrypick#494) +- Feat: 검색 위젯 (1673beta/cherrypick#125) +- Feat: 캡션 미설정 안내 표시 (1673beta/cherrypick#142) + - 노트를 게시하기 전에 첨부한 파일에 캡션이 없으면 경고를 표시합니다. + - 이 변경으로 이미지 뷰어의 파일 이름 영역에는 더 이상 캡션이 아닌 실제 파일 이름이 표시됩니다. +- Feat: 사용자 정의 스플래시 텍스트를 설정할 수 있음 (1673beta/cherrypick#153) +- Feat: 주사위 위젯 (1673beta/cherrypick#73) +- Feat: QR 코드를 생성하고 공유할 수 있음 + - 노트를 QR 코드로 공유 (1673beta/cherrypick#45) + - 사용자를 QR 코드로 공유 (1673beta/cherrypick#46), (1673beta/cherrypick#49) + - 갤러리를 QR 코드로 공유 (1673beta/cherrypick#51) + - 페이지를 QR 코드로 공유 (1673beta/cherrypick#53) + - Play를 QR 코드로 공유 +- Feat: 설정한 시간이 지나면 노트를 자동으로 삭제할 수 있음 (1673beta/cherrypick#70) +- Feat: 모바일 환경에서 하단 내비게이션 바를 개인화할 수 있음 + - `설정` - `내비게이션 바`의 `하단 내비게이션 바`에서 설정할 수 있습니다. +- Feat: 리버시 대전 중에 상대방에게 리액션을 보낼 수 있음 (misskey-dev/misskey#13119) +- Feat: 자동 번역 기능 + - 자동 번역은 번역 서비스의 API 제한을 방지하기 위해 자동으로 활성화되지 않으며, 기본적으로 비활성화되어 있습니다. + - `역할`에서 `자동 번역 기능 이용`를 활성화 하면 자동 번역을 사용할 수 있는 상태가 됩니다. + - 자동 번역 사용 권한을 역할별로 설정할 수 있습니다. + - 이후, 각 사용자별로 `설정` - `일반`에서 `자동 번역`을 활성화한 사용자는 자동 번역을 사용할 수 있습니다. + - 노트가 아래와 같이 설정된 경우에는 자동 번역을 사용하지 않습니다. + - 노트가 `내용 가리기`로 설정되어 있음 + - 내용이 긴 노트 + - 노트에 5개 이상의 파일이 포함되어 있음 +- Feat: 노트의 전체 대화와 전체 답글을 불러올 수 있음 (kokonect-link/cherrypick#495) + - `답글을 자동으로 더 보기`를 활성화하면 `더 보기` 버튼을 누르지 않아도 노트 내 답글을 전부 표시합니다. + - `대화를 자동으로 더 보기`를 활성화하면 `대화 보기` 버튼을 누르지 않아도 노트 내 대화를 전부 표시합니다. + +### Client +- Enhance: CherryPick 업데이트 페이지를 제어판 목록에 추가함 +- Enhance: Webhook 추가 버튼을 헤더로 이동해 디자인 개선 +- Enhance: 노트 번역 영역에서도 이모지를 눌러 이모지 메뉴를 열 수 있음 +- Enhance: 노트 상세 페이지에서 InstanceTicker를 클릭해 리모트에서 노트를 볼 수 있음 +- Enhance: 리액션 수신 범위에 따라 리액션 버튼의 툴팁 내용이 변경됨 +- Enhance: 설정 페이지 개선 + - 일반 설정에 있던 설정 중 디자인과 관련된 설정을 모양으로 옮겼습니다. +- Enhance: 외부 사이트로 이동할 때 경고 표시 (misskey-dev/misskey#13557) +- Enhance: 이미지의 확장자를 더욱 정확하게 표시함 + - APNG 형식의 이미지가 GIF로 표시되던 것을 APNG로 표시하도록 변경 +- Enhance: 이미지 뷰어가 파일 이름과 캡션을 동시에 표시하도록 변경 +- Enhance: 노트를 작성할 때 첨부한 파일에 캡션이 존재하는 경우 파일 목록에 아이콘을 표시함 +- Enhance: 미디어 숨기기 버튼의 디자인을 개선함 +- Enhance: 갤러리를 노트로 공유할 때 노트 내용에 줄바꿈을 적용함 +- Enhance: 노트 내용에 `検索`, `검색`, `search`이 포함되면 의도하지 않게 검색 블록이 활성화되는 문제를 개선함 + - 이제 노트에서 검색 블록을 활성화하려면 `[検索]`, `[검색]`, `[search]`으로만 사용할 수 있습니다. +- Enhance: 설정 페이지의 하단 여백 디자인을 조정함 +- Enhance: 노트 작성 시 `본문 미리보기`를 활성화한 경우, 본문에 내용이 있을 때만 표시되도록 변경함 +- Enhance: 노트 작성 폼의 일부 기능 버튼을 드롭 다운 메뉴로 이동함 +- Enhance: 노트나 프로필의 번역 버튼을 누르면 자동으로 내용을 펼침 +- Fix: 환경설정 백업 시 일부 설정이 누락되어 백업될 수 있음 +- Fix: 이미지 자르기를 할 때 이미지 크기 전체를 표시하지 못할 수 있음 +- Fix: 페이지에서 페이지 생성 버튼이 본문에 중복으로 표시됨 +- Fix: 노트 본문의 사용자 멘션 영역을 클릭하면 노트 상세 페이지가 표시됨 +- Fix: 역할 권한에 의해 번역 기능을 사용할 수 없을 때도 번역 버튼이 표시됨 + +### Server +- Enhance: 보안 향상을 위해 비밀번호 해싱 알고리즘이 `bcrypt`에서 `argon2`로 변경됨 (kokonect-link/cherrypick#511) + - 이제 72 바이트를 초과하는 비밀번호를 사용할 수 있습니다. + - 이로써 `Sharkey`, `FireFish`, `IceShrimp` 등의 클라이언트에서 `CherryPick`으로 이전할 때 암호 호환성이 보장됩니다. +- Enhance: `Sharkey`를 사용하는 서버의 사용자가 설정한 아바타 장식을 자동으로 불러옴 +- Fix: 이모지를 등록하거나 가져오려고 할 때 오류가 발생할 수 있음 (kokonect-link/cherrypick#487), (kokonect-link/cherrypick#508) +- Fix: 사용자 이름에 `.`이 있는 경우 멘션을 할 수 없음 (kokonect-link/cherrypick#509) + +--- + ## 4.11.1 출시일: 2024/8/30
기반 Misskey 버전: 2024.8.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32d3210c64..46dc898b75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -575,3 +575,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/Dockerfile b/Dockerfile index bee178ce28..2b53b07578 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,9 @@ WORKDIR /cherrypick 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/cherrypick-js/package.json", "./packages/cherrypick-js/"] COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] diff --git a/chart/files/default.yml b/chart/files/default.yml index e4689df792..92cbb3c97b 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 └───────────────────────────── diff --git a/idea/MkDisableSection.vue b/idea/MkDisableSection.vue new file mode 100644 index 0000000000..d177886569 --- /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 0000000000..f64d16800a --- /dev/null +++ b/idea/README.md @@ -0,0 +1 @@ +使われなくなったけど消すのは勿体ない(将来使えるかもしれない)コードを入れておくとこ diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index ec3285f5c2..c30037a6a2 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -461,7 +461,6 @@ language: "ভাষা" uiLanguage: "UI এর ভাষা" groupInvited: "আপনি একটি গ্রুপে আমন্ত্রিত হয়েছেন" aboutX: "{x} সম্পর্কে" -disableDrawer: "ড্রয়ার মেনু প্রদর্শন করবেন না" youHaveNoGroups: "আপনার কোন গ্রুপ নেই " joinOrCreateGroup: "একটি বিদ্যমান গ্রুপের আমন্ত্রণ পান বা একটি নতুন গ্রুপ তৈরি করুন৷" noHistory: "কোনো ইতিহাস নেই" @@ -939,9 +938,9 @@ _aboutMisskey: donate: "Misskey তে দান করুন" morePatrons: "আরও অনেকে আমাদের সাহায্য করছেন। তাদের সবাইকে ধন্যবাদ 🥰" patrons: "সমর্থনকারী" -_mfm: - cheatSheet: "MFM চিটশিট" - intro: "MFM একটি মার্কআপ ভাষা যা CherryPick-এর মধ্যে বিভিন্ন জায়গায় ব্যবহার করা যেতে পারে। এখানে আপনি MFM-এর সিনট্যাক্সগুলির একটি তালিকা দেখতে পারবেন।" +_cfm: + cheatSheet: "CFM চিটশিট" + intro: "CFM একটি মার্কআপ ভাষা যা CherryPick-এর মধ্যে বিভিন্ন জায়গায় ব্যবহার করা যেতে পারে। এখানে আপনি CFM-এর সিনট্যাক্সগুলির একটি তালিকা দেখতে পারবেন।" dummy: "মিসকি ফেডিভার্সের বিশ্বকে প্রসারিত করে" mention: "উল্লেখ" mentionDescription: "@ চিহ্ন + ব্যবহারকারীর নাম একটি নির্দিষ্ট ব্যবহারকারীকে নির্দেশ করতে ব্যবহার করা যায়।" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 17af74ba4d..dd9101baf0 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,13 +509,12 @@ 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" signinHistory: "Historial d'autenticacions" -enableAdvancedMfm: "Habilitar l'MFM avançat" -enableAnimatedMfm: "Habilitar l'MFM amb moviment" +enableAdvancedMfm: "Habilitar l'CFM avançat" +enableAnimatedMfm: "Habilitar l'CFM amb moviment" doing: "Processant..." category: "Categoria" tags: "Etiquetes" @@ -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 CherryPick." +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 CherryPick tal com és (sense cap canvi al codi font), introdueix https://github.com/kokonect-link/cherrypick" +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" @@ -1202,8 +1229,8 @@ remainingN: "Queden: {n}" overwriteContentConfirm: "Vols substituir el contingut actual?" seasonalScreenEffect: "Efectes de pantalla segons les estacions" decorate: "Decorar" -addMfmFunction: "Afegeix funcions MFM" -enableQuickAddMfmFunction: "Activar accés ràpid per afegir funcions MFM" +addMfmFunction: "Afegeix funcions CFM" +enableQuickAddMfmFunction: "Activar accés ràpid per afegir funcions CFM" bubbleGame: "Bubble Game" sfx: "Efectes de so" soundWillBePlayed: "Es reproduiran efectes de so" @@ -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 CherryPick." 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! 🥰" @@ -1902,6 +1979,7 @@ _soundSettings: driveFileTypeWarnDescription: "Seleccionar un fitxer d'àudio " driveFileDurationWarn: "L'àudio és massa llarg" driveFileDurationWarnDescription: "Els àudios molt llargs pot interrompre l'ús de CherryPick. Vols continuar?" + driveFileError: "El so no es pot carregar. Canvia la configuració" _ago: future: "Futur " justNow: "Ara mateix" @@ -1954,6 +2032,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." @@ -2027,22 +2106,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" @@ -2108,25 +2238,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 " @@ -2149,6 +2328,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 " @@ -2159,6 +2339,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ó" @@ -2188,29 +2370,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" @@ -2221,18 +2424,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" @@ -2245,6 +2507,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" @@ -2271,5 +2543,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 7fc72bdded..e6ec005d82 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -471,12 +471,11 @@ 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í" -enableAdvancedMfm: "Zapnout pokročilé MFM" -enableAnimatedMfm: "Zapnout animované MFM" +enableAdvancedMfm: "Zapnout pokročilé CFM" +enableAnimatedMfm: "Zapnout animované CFM" doing: "Procesuju..." category: "Kategorie" tags: "Štítky" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 32cfe3614c..fa7f570aa5 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -501,14 +501,13 @@ groupInvited: "Du wurdest in eine Gruppe eingeladen" aboutX: "Über {x}" emojiStyle: "Emoji-Stil" native: "Nativ" -disableDrawer: "Keine ausfahrbaren Menüs verwenden" youHaveNoGroups: "Keine Gruppen vorhanden" joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene." showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" -enableAdvancedMfm: "Erweitertes MFM aktivieren" -enableAnimatedMfm: "Animiertes MFM aktivieren" +enableAdvancedMfm: "Erweitertes CFM aktivieren" +enableAnimatedMfm: "Animiertes CFM aktivieren" doing: "In Bearbeitung …" category: "Kategorie" tags: "Aliasse" @@ -1195,7 +1194,7 @@ cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Besch doReaction: "Reagieren" code: "Code" decorate: "Dekorieren" -addMfmFunction: "MFM hinzufügen" +addMfmFunction: "CFM hinzufügen" sfx: "Soundeffekte" lastNDays: "Letzten {n} Tage" surrender: "Abbrechen" @@ -1709,9 +1708,9 @@ _displayOfSensitiveMedia: respect: "Sensible Medien verbergen" ignore: "Sensible Medien anzeigen" force: "Alle Medien verbergen" -_mfm: - cheatSheet: "MFM Spickzettel" - intro: "MFM ist eine Misskey-exklusive Markup-Sprache, die in Misskey an vielen Stellen verwendet werden kann. Hier kannst du eine Liste von verfügbarer MFM-Syntax einsehen." +_cfm: + cheatSheet: "CFM Spickzettel" + intro: "CFM ist eine CherryPick-exklusive Markup-Sprache, die in CherryPick an vielen Stellen verwendet werden kann. Hier kannst du eine Liste von verfügbarer CFM-Syntax einsehen." dummy: "CherryPick erweitert die Welt des Fediverse" mention: "Erwähnung" mentionDescription: "Mit At-Zeichen und Benutzername kann ein individueller Nutzer angegeben werden." @@ -1774,7 +1773,7 @@ _mfm: rotate: "Drehen" rotateDescription: "Dreht den Inhalt um einen angegebenen Winkel." plain: "Schlicht" - plainDescription: "Deaktiviert jegliche MFM-Syntax, die sich innerhalb dieses MFM-Effekts befindet." + plainDescription: "Deaktiviert jegliche CFM-Syntax, die sich innerhalb dieses CFM-Effekts befindet." _instanceTicker: none: "Nie anzeigen" remote: "Für Benutzer fremder Instanzen anzeigen" diff --git a/locales/en-US.yml b/locales/en-US.yml index a1cbf9b525..e42d5cdb1f 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1,5 +1,30 @@ --- _lang_: "English" +autoLoadMoreReplies: "Show more automatically replies" +autoLoadMoreConversation: "Show more conversation automatically" +useAutoTranslate: "Automatic translation" +useAutoTranslateDescription: "Enabling the automatic translation feature automatically translates all notes in the timeline, which could potentially result in the API restriction policy set by the translation service provider temporarily disabling the translation feature.\n\nDo you still want to activate it?" +cantUseAutoTranslateDescription: "The server administrator has disabled automatic translation.\nPlease contact your server administrator to enable automatic translation." +cantUseAutoTranslateCaption: "When enabled, automatic translation will be applied when available again." +widgets: "Widgets" +postNote: "Post note" +bottomNavbar: "Bottom navigation bar" +bottomNavbarDescription: "This setting is only available in a mobile environment." +scheduledNoteDelete: "Schedule note deletion" +getQRCode: "Get QR code" +customSplashText: "Custom splash text" +customSplashTextDescription: "This text will be displayed on the loading page." +showNoAltWarning: "Caption unset warning" +showNoAltWarningDescription: "Display a warning when no alternate text is set in the image" +filesGridLayoutInUserPage: "Change the media tab to grid layout" +filesGridLayoutInUserPageDescription: "When this function is turned on, the Media tab on your page is shown in album format.\nTurning it off will change to the original note timeline." +showReplyTargetNoteInSemiTransparent: "Show reply target note in semi-transparent" +noteFooterButton: "Show action buttons in notes" +collapseReplies: "Collapse notes written in reply" +collapseRepliesDescription: "Collapse and displays notes written as replies.\nReacted notes are not affected." +repliedBy: "Replied by {user}" +collapseLongNoteContent: "Collapse long notes" +alwaysShowCw: "Always show content set to 'Hide content'" forceRenoteVisibilitySelector: "Specify visibility of renote" cherrypickLabs: "CherryPick Labs" cherrypickLabsDescription: "Why not try some of the features that are still under development? Some features may not work properly." @@ -63,10 +88,6 @@ displayBanner: "Display Banner Image" requireRefresh: "When the page needs to refresh" performanceWarning: "High resource usage can result in higher device temperatures and faster battery consumption" photosensitiveSeizuresWarning: "Can cause photosensitive seizures" -friendlyEnableNotifications: "Enable the notifications area" -friendlyDisableNotifications: "Disable the notifications area" -friendlyEnableWidgets: "Enable the widgets area" -friendlyDisableWidgets: "Disable the widgets area" useBoldFont: "Bold Text" newNoteReceivedNotification: "When receive a new note notification" disableRightClick: "Prohibit right click" @@ -600,17 +621,16 @@ groupInvited: "You've been invited to a group" aboutX: "About {x}" emojiStyle: "Emoji style" native: "Native" -disableDrawer: "Don't use drawer-style menus" youHaveNoGroups: "You have no groups" joinOrCreateGroup: "Get invited to a group or create your own." showNoteActionsOnlyHover: "Only show note actions on hover" showReactionsCount: "See the number of reactions in notes" noHistory: "No history available" signinHistory: "Login history" -enableAdvancedMfm: "Enable advanced MFM" -enableAdvancedMfmDescription: "When enabled, various MFM features, such as MFM with animated are available." -enableAnimatedMfm: "Enable animated MFM" -enableAnimatedMfmDescription: "When enabled, moves text that uses MFM grammar or emoji." +enableAdvancedMfm: "Enable advanced CFM" +enableAdvancedMfmDescription: "When enabled, various CFM features, such as CFM with animated are available." +enableAnimatedMfm: "Enable animated CFM" +enableAnimatedMfmDescription: "When enabled, moves text that uses CFM grammar or emoji." doing: "Processing..." category: "Category" tags: "Aliases" @@ -1144,7 +1164,7 @@ thisPostMayBeAnnoyingCancel: "Cancel" thisPostMayBeAnnoyingIgnore: "Post anyway" collapseRenotes: "Collapse renotes you've already seen" collapseRenotesDescription: "Collapse notes that you've reacted to or renoted before." -collapseDefault: "Collapse notes using specific MFM syntax" +collapseDefault: "Collapse notes using specific CFM syntax" internalServerError: "Internal Server Error" internalServerErrorDescription: "The server has run into an unexpected error." copyErrorInfo: "Copy error details" @@ -1185,7 +1205,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" @@ -1340,8 +1360,8 @@ remainingN: "Remaining: {n}" overwriteContentConfirm: "Are you sure you want to overwrite the current content?" seasonalScreenEffect: "Seasonal Screen Effect" decorate: "Decorate" -addMfmFunction: "Add MFM" -enableQuickAddMfmFunction: "Show advanced MFM picker" +addMfmFunction: "Add CFM" +enableQuickAddMfmFunction: "Show advanced CFM picker" bubbleGame: "Bubble Game" sfx: "Sound Effects" soundWillBePlayed: "Sound will be played" @@ -1375,12 +1395,19 @@ confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media" sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?" createdLists: "Created lists" createdAntennas: "Created antennas" +fromX: "From {x}" +genEmbedCode: "Generate embed code" +noteOfThisUser: "Notes by this user" +clipNoteLimitExceeded: "No more notes can be added to this clip." showUnreadNotificationsCount: "Show the number of unread notifications" showCatOnly: "Show only cats" additionalPermissionsForFlash: "Allow to add permission to Play" thisFlashRequiresTheFollowingPermissions: "This Play requires the following permissions" doYouWantToAllowThisPlayToAccessYourAccount: "Do you want to allow this Play to access your account?" translateProfile: "Translate profile" +trustedLinkUrlPatterns: "Link to external site warning exclusion list" +trustedLinkUrlPatternsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition. Using surrounding keywords with slashes will turn them into a regular expression. If you write only the domain name, it will be a backward match." +open: "Open" _nsfwOpenBehavior: click: "Click to open" doubleClick: "Double click to open" @@ -1468,11 +1495,15 @@ _cherrypick: reactableRemoteReaction: "Allow remote custom emoji reactions to react if there is an emoji with the same name on this server." showFollowingMessageInsteadOfButton: "Do not show the follow button in the notification field if you are already following someone" mobileHeaderChange: "Header design change in mobile environment" - renameTheButtonInPostFormToNya: "Change the \"Note\" button on the note-posting form to \"Nyan!\"" - renameTheButtonInPostFormToNyaDescription: "Outside of the note-posting form, they are still as \"Note\"." + renameTheButtonInPostFormToNya: "Change the \"Note\" button on the posting form to \"Nyan!\"" + renameTheButtonInPostFormToNyaDescription: "Outside of the posting form, they are still as \"Note\"." + enableWidgetsArea: "Enable the widgets area" + disableWidgetsArea: "Disable the widgets area" + friendlyUiEnableNotificationsArea: "Enable the notifications area" + friendlyUiDisableNotificationsArea: "Disable the notifications area" enableLongPressOpenAccountMenu: "Press and hold to open the account menu" enableLongPressOpenAccountMenuDescription: "It can be opened by long-pressing the Timeline tab at the bottom of the screen." - friendlyShowAvatarDecorationsInNavBtn: "Show avatar decorations on floating buttons" + friendlyUiShowAvatarDecorationsInNavBtn: "Show avatar decorations on floating buttons" _bannerDisplay: all: "All" topBottom: "Top and Bottom" @@ -1491,7 +1522,7 @@ _initialAccountSetting: privacySetting: "Privacy settings" fontSizeSetting: "Font size settings" blurEffectsSetting: "Blur effects settings" - mfmAndAnimatedImagesSetting: "MFM and Animated images settings" + mfmAndAnimatedImagesSetting: "CFM and Animated images settings" theseSettingsCanEditLater: "You can always change these settings later." youCanEditMoreSettingsInSettingsPageLater: "There are many more settings you can configure from the \"Settings\" page. Be sure to visit it later." followUsers: "Try following some users that interest you to build up your timeline." @@ -1948,6 +1979,8 @@ _role: canHideAds: "Can hide ads" canSearchNotes: "Usage of note search" canUseTranslator: "Translator usage" + canUseAutoTranslate: "Automatic translation usage" + canUseAutoTranslateDescription: "Users who enable the automatic translation feature will have all notes in their timeline automatically translated, which can very quickly reach the API limits set by the translation service provider, potentially temporarily disabling the translation feature.\nThis means that all users on the server may be temporarily unable to use the API.\nAdditionally, depending on the translation service provider, you may be charged excessive fees for API usage.\n\nDo you still want to enable it?" avatarDecorationLimit: "Maximum number of avatar decorations that can be applied" _condition: roleAssignedTo: "Assigned to manual roles" @@ -2080,9 +2113,9 @@ _displayOfSensitiveMedia: respect: "Hide media marked as sensitive" ignore: "Display media marked as sensitive" force: "Hide all media" -_mfm: - cheatSheet: "MFM Cheatsheet" - intro: "MFM is a Misskey-exclusive markup language that can be used in many places. Here you can view a list of all available MFM syntax." +_cfm: + cheatSheet: "CFM Cheatsheet" + intro: "CFM is a CherryPick-exclusive markup language that can be used in many places. Here you can view a list of all available CFM syntax." dummy: "CherryPick expands the world of the Fediverse" mention: "Mention" mentionDescription: "You can specify a user by using an At-Symbol and a username." @@ -2155,7 +2188,7 @@ _mfm: bg: "Background color" bgDescription: "Set the background color to the specified value." plain: "Plain" - plainDescription: "Deactivates the effects of all MFM contained within this MFM effect." + plainDescription: "Deactivates the effects of all CFM contained within this CFM effect." ruby: "Ruby" rubyDescription: "Display ruby characters over the text." _instanceTicker: @@ -2475,6 +2508,8 @@ _widgets: chooseList: "Select a list" clicker: "Clicker" birthdayFollowings: "Users who celebrate their birthday today" + search: "Search" + dice: "Dice" _cw: hide: "Hide" show: "Show content" @@ -2757,7 +2792,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: @@ -2883,7 +2918,7 @@ _dataSaver: description: "URL preview thumbnail images will no longer be loaded." _code: title: "Code highlighting" - description: "If code highlighting notations are used in MFM, etc., they will not load until tapped. Syntax highlighting requires downloading the highlight definition files for each programming language. Therefore, disabling the automatic loading of these files is expected to reduce the amount of communication data." + description: "If code highlighting notations are used in CFM, etc., they will not load until tapped. Syntax highlighting requires downloading the highlight definition files for each programming language. Therefore, disabling the automatic loading of these files is expected to reduce the amount of communication data." _hemisphere: N: "Northern Hemisphere" S: "Southern Hemisphere" @@ -2931,6 +2966,7 @@ _reversi: allowIrregularRules: "Irregular rules (completely free)" disallowIrregularRules: "No irregular rules" showBoardLabels: "Display row and column numbering on the board" + showReaction: "Show the opponent reaction" useAvatarAsStone: "Turn stones into user avatars" _offlineScreen: title: "Offline - cannot connect to the server" @@ -2958,6 +2994,20 @@ _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." _abuse: _resolver: 1hour: "one hour" @@ -2982,3 +3032,24 @@ _imageCompressionMode: noResizeCompress: "Compression without resize" resizeCompressLossy: "Resize and lossy compression" noResizeCompressLossy: "Lossy compression without resize" +_externalNavigationWarning: + title: "Navigate to an external site" + description: "Leave {host} and go to an external site" + trustThisDomain: "Trust this domain on this device in the future" +_altWarning: + noAltWarning: "No alternate text is configured in the file." + noAltWarningDescription: "You can change this setting in \"Settings - Appearance\"." +_dice: + rollDice: "Roll the dice" + diceCount: "Number of dice" + diceFaces: "Number of dice faces" +_scheduledNoteDelete: + expiration: "End of note deletion" + at: "End at..." + after: "End after..." + deadlineDate: "End date" + deadlineTime: "Time" + duration: "Duration" +_getQRCode: + title: "Scan QR Code" + description: "Can scan or share the QR code below." diff --git a/locales/es-ES.yml b/locales/es-ES.yml index c54f8e6a95..81b180ed1c 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -109,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ú" @@ -152,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" @@ -178,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" @@ -504,15 +512,14 @@ groupInvited: "Invitado al grupo" aboutX: "Acerca de {x}" emojiStyle: "Estilo de emoji" native: "Nativo" -disableDrawer: "No mostrar los menús en cajones" youHaveNoGroups: "Sin grupos" joinOrCreateGroup: "Obtenga una invitación para unirse al grupos o puede crear su propio grupo." 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" signinHistory: "Historial de ingresos" -enableAdvancedMfm: "Habilitar MFM avanzado" -enableAnimatedMfm: "Habilitar MFM con movimiento" +enableAdvancedMfm: "Habilitar CFM avanzado" +enableAnimatedMfm: "Habilitar CFM con movimiento" doing: "Voy en camino" category: "Categoría" tags: "Etiqueta" @@ -1109,6 +1116,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." @@ -1220,8 +1229,8 @@ remainingN: "Faltan: {n}" overwriteContentConfirm: "¿Quieres sustituir todo el contenido actual?" seasonalScreenEffect: "Efectos de pantalla asociados a estaciones" decorate: "Decorar" -addMfmFunction: "Añadir función MFM" -enableQuickAddMfmFunction: "Activar acceso rápido para añadir funciones MFM" +addMfmFunction: "Añadir función CFM" +enableQuickAddMfmFunction: "Activar acceso rápido para añadir funciones CFM" bubbleGame: "Bubble Game" sfx: "Efectos de sonido" soundWillBePlayed: "Se reproducirán efectos sonoros" @@ -1828,9 +1837,9 @@ _displayOfSensitiveMedia: respect: "Esconder medios marcados como sensibles" ignore: "Mostrar medios marcados como sensibles" force: "Esconder todala multimedia" -_mfm: - cheatSheet: "Hoja de referencia de MFM" - intro: "MFM es un lenguaje de marcado dedicado que se puede usar en varios lugares dentro de Misskey. Aquí puede ver una lista de sintaxis disponibles en MFM." +_cfm: + cheatSheet: "Hoja de referencia de CFM" + intro: "CFM es un lenguaje de marcado dedicado que se puede usar en varios lugares dentro de CherryPick. Aquí puede ver una lista de sintaxis disponibles en CFM." dummy: "CherryPick expande el mundo de la Fediverso" mention: "Menciones" mentionDescription: "El signo @ seguido de un nombre de usuario se puede utilizar para notificar a un usuario en particular." @@ -1893,7 +1902,7 @@ _mfm: rotate: "Rotar" rotateDescription: "Rota el contenido a un ángulo especificado." plain: "Plano" - plainDescription: "Desactiva los efectos de todo el contenido MFM con este efecto MFM." + plainDescription: "Desactiva los efectos de todo el contenido CFM con este efecto CFM." _instanceTicker: none: "No mostrar" remote: "Mostrar a usuarios remotos" @@ -2485,6 +2494,8 @@ _abuseReport: _notificationRecipient: _recipientType: mail: "Correo" + webhook: "Webhook" + keywords: "Palabras Clave" _moderationLogTypes: createRole: "Rol creado" deleteRole: "Rol eliminado" @@ -2582,12 +2593,13 @@ _dataSaver: description: "Desactiva la carga de vistas previas de las URLs." _code: title: "Resaltar código" - description: "Si se usa resaltado de código en MFM, etc., no se cargará hasta pulsar en ello. El resaltado de sintaxis requiere la descarga de archivos de definición para cada lenguaje de programación. Debido a esto, al deshabilitar la carga automática de estos archivos reducirás el consumo de datos." + description: "Si se usa resaltado de código en CFM, etc., no se cargará hasta pulsar en ello. El resaltado de sintaxis requiere la descarga de archivos de definición para cada lenguaje de programación. Debido a esto, al deshabilitar la carga automática de estos archivos reducirás el consumo de datos." _hemisphere: N: "Hemisferio norte" 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 dc971cf12d..85dec114af 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -503,15 +503,14 @@ groupInvited: "Invité au groupe" aboutX: "À propos de {x}" emojiStyle: "Style des émojis" native: "Natif" -disableDrawer: "Les menus ne s'affichent pas dans le tiroir" youHaveNoGroups: "Vous n’avez aucun groupe" joinOrCreateGroup: "Vous pouvez être invité·e à rejoindre des groupes existants ou créer votre propre nouveau groupe." showNoteActionsOnlyHover: "Afficher les actions de note uniquement au survol" showReactionsCount: "Afficher le nombre de réactions des notes" noHistory: "Pas d'historique" signinHistory: "Historique de connexion" -enableAdvancedMfm: "Activer la MFM avancée" -enableAnimatedMfm: "Activer le MFM animé" +enableAdvancedMfm: "Activer la CFM avancée" +enableAnimatedMfm: "Activer le CFM animé" doing: "En cours..." category: "Catégorie" tags: "Étiquettes" @@ -1220,8 +1219,8 @@ remainingN: "Restants : {n}" overwriteContentConfirm: "Voulez-vous remplacer le contenu actuel ?" seasonalScreenEffect: "Effet d'écran saisonnier" decorate: "Décorer" -addMfmFunction: "Insérer MFM" -enableQuickAddMfmFunction: "Afficher le sélecteur de MFM avancé" +addMfmFunction: "Insérer CFM" +enableQuickAddMfmFunction: "Afficher le sélecteur de CFM avancé" bubbleGame: "Jeu de bulles" sfx: "Effets sonores" soundWillBePlayed: "Le son sera joué" @@ -1623,9 +1622,9 @@ _aboutMisskey: projectMembers: "Membres du projet" _displayOfSensitiveMedia: force: "Masquer tous les médias" -_mfm: - cheatSheet: "Antisèche MFM" - intro: "MFM est un langage Markdown spécifique utilisable ici et là dans Misskey. Vous pouvez vérifier ici les structures utilisables avec MFM." +_cfm: + cheatSheet: "Antisèche CFM" + intro: "CFM est un langage Markdown spécifique utilisable ici et là dans CherryPick. Vous pouvez vérifier ici les structures utilisables avec CFM." dummy: "La Fédiverse s'agrandit avec CherryPick" mention: "Mentionner" mentionDescription: "Vous pouvez afficher un utilisateur spécifique en indiquant une arobase suivie d'un nom d'utilisateur" @@ -2265,7 +2264,7 @@ _dataSaver: description: "Les vignettes d'aperçu des URL ne seront plus chargées." _code: title: "Mise en évidence du code" - description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données." + description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la CFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données." _reversi: waitingBoth: "Préparez-vous" total: "Total" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 17e433dc07..347fe345da 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" @@ -512,15 +514,14 @@ groupInvited: "Telah diundang ke grup" aboutX: "Tentang {x}" emojiStyle: "Gaya emoji" native: "Native" -disableDrawer: "Jangan gunakan menu bergaya laci" youHaveNoGroups: "Kamu tidak memiliki grup" joinOrCreateGroup: "Bergabunglah dengan grup atau kamu dapat membuat grupmu sendiri." showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk" showReactionsCount: "Lihat jumlah reaksi dalam catatan" noHistory: "Tidak ada riwayat" signinHistory: "Riwayat masuk" -enableAdvancedMfm: "Nyalakan MFM tingkat lanjut" -enableAnimatedMfm: "Nyalakan animasi MFM" +enableAdvancedMfm: "Nyalakan CFM tingkat lanjut" +enableAnimatedMfm: "Nyalakan animasi CFM" doing: "Sedang berkerja..." category: "Kategori" tags: "Tandai" @@ -1229,7 +1230,7 @@ overwriteContentConfirm: "Apakah kamu yakin untuk menimpa konten saat ini?" seasonalScreenEffect: "Efek layar musiman" decorate: "Dekor" addMfmFunction: "Tambahkan dekorasi" -enableQuickAddMfmFunction: "Tampilkan pemilih MFM tingkat lanjut" +enableQuickAddMfmFunction: "Tampilkan pemilih CFM tingkat lanjut" bubbleGame: "Bubble Game" sfx: "Efek Suara" soundWillBePlayed: "Suara yang akan dimainkan" @@ -1845,9 +1846,9 @@ _displayOfSensitiveMedia: respect: "Sembunyikan media yang ditandai sensitif" ignore: "Tampilkan media yang ditandai sensitif" force: "Sembunyikan semua media" -_mfm: - cheatSheet: "Contekan MFM" - intro: "MFM adalah Misskey-exclusive Markup Language yang dapat digunakan di banyak tempat. Berikut kamu bisa melihat daftar dari syntax MFM yang ada." +_cfm: + cheatSheet: "Contekan CFM" + intro: "CFM adalah CherryPick-exclusive Markup Language yang dapat digunakan di banyak tempat. Berikut kamu bisa melihat daftar dari syntax CFM yang ada." dummy: "CherryPick membentangkan dunia Fediverse" mention: "Sebut" mentionDescription: "Kamu dapat menentukan pengguna tertentu dengan menggunakan simbol-At dan nama engguna mereka." @@ -2602,7 +2603,7 @@ _dataSaver: description: "Gambar kecil URL pratinjau tidak akan dimuat lagi." _code: title: "Penyorotan kode" - description: "Jika notasi penyorotan kode digunakan di MFM, dll. Fungsi tersebut tidak akan dimuat apabila tidak diketuk. Penyorotan sintaks membutuhkan pengunduhan berkas definisi penyorotan untuk setiap bahasa pemrograman. Oleh sebab itu, menonaktifkan pemuatan otomatis dari berkas ini dilakukan untuk mengurangi jumlah komunikasi data." + description: "Jika notasi penyorotan kode digunakan di CFM, dll. Fungsi tersebut tidak akan dimuat apabila tidak diketuk. Penyorotan sintaks membutuhkan pengunduhan berkas definisi penyorotan untuk setiap bahasa pemrograman. Oleh sebab itu, menonaktifkan pemuatan otomatis dari berkas ini dilakukan untuk mengurangi jumlah komunikasi data." _hemisphere: N: "Bumi belahan utara" S: "Bumi belahan selatan" diff --git a/locales/index.d.ts b/locales/index.d.ts index 63f8aefe6e..7ac337e7a1 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13,6 +13,111 @@ export interface Locale extends ILocale { * 日本語 */ "_lang_": string; + /** + * 返信を自動でもっと見る + */ + "autoLoadMoreReplies": string; + /** + * 会話を自動でもっと見る + */ + "autoLoadMoreConversation": string; + /** + * 自動翻訳 + */ + "useAutoTranslate": string; + /** + * 自動翻訳機能を有効にすると、タイムラインのすべてのノートが自動的に翻訳され、これにより翻訳サービス提供者が設定したAPI制限ポリシーにより、翻訳機能を一時的に使用できなくなる可能性があります。 + * + * それでも続けましょうか? + */ + "useAutoTranslateDescription": string; + /** + * サーバー管理者が自動翻訳を使用できないように設定しました。 + * 自動翻訳を使用するには、サーバー管理者にお問い合わせください。 + */ + "cantUseAutoTranslateDescription": string; + /** + * 有効にすると、再利用できるときに自動翻訳を適用します。 + */ + "cantUseAutoTranslateCaption": string; + /** + * ウィジェット + */ + "widgets": string; + /** + * ノートを作成 + */ + "postNote": string; + /** + * 下のナビゲーションバー + */ + "bottomNavbar": string; + /** + * この設定は、モバイル環境でのみ使用できます。 + */ + "bottomNavbarDescription": string; + /** + * ノートの削除を予約 + */ + "scheduledNoteDelete": string; + /** + * QRコードを取得 + */ + "getQRCode": string; + /** + * カスタムスプラッシュテキスト + */ + "customSplashText": string; + /** + * ロード画面に表示されるテキストを設定します。改行で区切って複数設定できます。 + */ + "customSplashTextDescription": string; + /** + * キャプション未設定案内 + */ + "showNoAltWarning": string; + /** + * 画像に代替テキストが設定されていない場合に警告を表示する + */ + "showNoAltWarningDescription": string; + /** + * メディアタブをグリッドレイアウトに変更 + */ + "filesGridLayoutInUserPage": string; + /** + * この設定をオンにすると、ユーザーページのメディアタブがアルバム形式で表示されます。 + * オフにすると、元のノートのタイムラインに変更されます。 + */ + "filesGridLayoutInUserPageDescription": string; + /** + * 返信対象ノートを半透明に表示 + */ + "showReplyTargetNoteInSemiTransparent": string; + /** + * ノートにアクションボタンを表示 + */ + "noteFooterButton": string; + /** + * 返信のリノートのスマート省略 + */ + "collapseReplies": string; + /** + * 返信で作成されたノートをたたんで表示します。 + * リアクションしたノートは影響を受けません。 + */ + "collapseRepliesDescription": string; + /** + * {user}が返信を作成しました + */ + "repliedBy": ParameterizedString<"user">; + /** + * 内容の長いノートを省略して表示 + */ + "collapseLongNoteContent": string; + /** + * 「内容を隠す」で設定した内容を常に表示する + */ + "alwaysShowCw": string; /** * リノートの公開範囲を指定 */ @@ -270,22 +375,6 @@ export interface Locale extends ILocale { * 光敏感性発作を起こす可能性があります */ "photosensitiveSeizuresWarning": string; - /** - * 通知領域を有効化 - */ - "friendlyEnableNotifications": string; - /** - * 通知領域を無効化 - */ - "friendlyDisableNotifications": string; - /** - * ウィジェット領域を有効化 - */ - "friendlyEnableWidgets": string; - /** - * ウィジェット領域を無効化 - */ - "friendlyDisableWidgets": string; /** * 文字を太くする */ @@ -1286,6 +1375,14 @@ export interface Locale extends ILocale { * メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。 */ "mediaSilencedInstancesDescription": string; + /** + * 連合を許可するサーバー + */ + "federationAllowedHosts": string; + /** + * 連合を許可するサーバーのホストを改行で区切って設定します。 + */ + "federationAllowedHostsDescription": string; /** * ミュートとブロック */ @@ -1682,6 +1779,10 @@ export interface Locale extends ILocale { * ファイルを追加 */ "addFile": string; + /** + * ファイルを表示 + */ + "showFile": string; /** * ドライブは空です */ @@ -2423,9 +2524,21 @@ export interface Locale extends ILocale { */ "native": string; /** - * メニューをドロワーで表示しない + * メニューのスタイル + */ + "menuStyle": string; + /** + * スタイル */ - "disableDrawer": string; + "style": string; + /** + * ドロワー + */ + "drawer": string; + /** + * ポップアップ + */ + "popup": string; /** * グループがありません */ @@ -2451,19 +2564,19 @@ export interface Locale extends ILocale { */ "signinHistory": string; /** - * 高度なMFMを有効にする + * 高度なCFMを有効にする */ "enableAdvancedMfm": string; /** - * 有効にすると、動きのあるMFMのようなさまざまなMFM機能が使用できます。 + * 有効にすると、動きのあるCFMのようなさまざまなCFM機能が使用できます。 */ "enableAdvancedMfmDescription": string; /** - * 動きのあるMFMを有効にする + * 動きのあるCFMを有効にする */ "enableAnimatedMfm": string; /** - * 有効にすると、MFM文法または絵文字を使用するテキストが動きます。 + * 有効にすると、CFM文法または絵文字を使用するテキストが動きます。 */ "enableAnimatedMfmDescription": string; /** @@ -2790,6 +2903,14 @@ export interface Locale extends ILocale { * スクラッチパッドは、AiScriptの実験環境を提供します。CherryPickと対話するコードの記述、実行、結果の確認ができます。 */ "scratchpadDescription": string; + /** + * UIインスペクター + */ + "uiInspector": string; + /** + * メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。 + */ + "uiInspectorDescription": string; /** * 出力 */ @@ -3532,7 +3653,7 @@ export interface Locale extends ILocale { */ "narrow": string; /** - * 設定はページリロード後に反映されます。今すぐリロードしますか? + * 設定はページリロード後に反映されます。 */ "reloadToApplySetting": string; /** @@ -4600,7 +4721,7 @@ export interface Locale extends ILocale { */ "collapseRenotesDescription": string; /** - * 特定のMFM構文を含むノートを省略して表示 + * 特定のCFM構文を含むノートを省略して表示 */ "collapseDefault": string; /** @@ -5388,7 +5509,7 @@ export interface Locale extends ILocale { */ "addMfmFunction": string; /** - * 高度なMFMのピッカーを表示する + * 高度なCFMのピッカーを表示する */ "enableQuickAddMfmFunction": string; /** @@ -5523,6 +5644,58 @@ export interface Locale extends ILocale { * 作成したアンテナ */ "createdAntennas": string; + /** + * {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; + /** + * フォロワーへのメッセージ + */ + "messageToFollower": string; /** * 未読の通知の数を表示する */ @@ -5547,6 +5720,18 @@ export interface Locale extends ILocale { * プロフィールを翻訳する */ "translateProfile": string; + /** + * 外部サイトへのリンク警告 除外リスト + */ + "trustedLinkUrlPatterns": string; + /** + * スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。 + */ + "trustedLinkUrlPatternsDescription": string; + /** + * 開く + */ + "open": string; "_nsfwOpenBehavior": { /** * タップして開く @@ -5875,6 +6060,22 @@ export interface Locale extends ILocale { * にゃあにゃんにゃんにゃんにゃにゃん? */ "renameTheButtonInPostFormToNyaDescription": string; + /** + * ウィジェット領域を有効化 + */ + "enableWidgetsArea": string; + /** + * ウィジェット領域を無効化 + */ + "disableWidgetsArea": string; + /** + * 通知領域を有効化 + */ + "friendlyUiEnableNotificationsArea": string; + /** + * 通知領域を無効化 + */ + "friendlyUiDisableNotificationsArea": string; /** * 長押しでアカウントメニューを開く */ @@ -5886,7 +6087,7 @@ export interface Locale extends ILocale { /** * フローティングボタンにアイコンのデコレーションを表示 */ - "friendlyShowAvatarDecorationsInNavBtn": string; + "friendlyUiShowAvatarDecorationsInNavBtn": string; }; "_bannerDisplay": { /** @@ -5954,7 +6155,7 @@ export interface Locale extends ILocale { */ "blurEffectsSetting": string; /** - * MFMとアニメーション画像設定 + * CFMとアニメーション画像設定 */ "mfmAndAnimatedImagesSetting": string; /** @@ -6405,6 +6606,10 @@ export interface Locale extends ILocale { * 有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。 */ "fanoutTimelineDbFallbackDescription": string; + /** + * 有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。 + */ + "reactionsBufferingDescription": string; /** * 問い合わせ先URL */ @@ -7594,10 +7799,42 @@ export interface Locale extends ILocale { * 翻訳機能の利用 */ "canUseTranslator": string; + /** + * 自動翻訳機能の利用 + */ + "canUseAutoTranslate": string; + /** + * 自動翻訳機能を有効にしたユーザーは、タイムラインのすべてのノートが自動的に翻訳され、これにより翻訳サービス提供者が設定したAPI制限に非常に早く到達し、翻訳機能を一時的に使用できなくなる可能性があります。 + * これは、サーバー内のすべてのユーザーがAPIを一時的に使用できなくなる可能性があることを意味します。 + * また、翻訳サービスの提供者によっては、APIの使用による料金が過度に発生する可能性があります。 + * + * それでも続けましょうか? + */ + "canUseAutoTranslateDescription": string; /** * アイコンデコレーションの最大取付個数 */ "avatarDecorationLimit": string; + /** + * アンテナのインポートを許可 + */ + "canImportAntennas": string; + /** + * ブロックのインポートを許可 + */ + "canImportBlocking": string; + /** + * フォローのインポートを許可 + */ + "canImportFollowing": string; + /** + * ミュートのインポートを許可 + */ + "canImportMuting": string; + /** + * リストのインポートを許可 + */ + "canImportUserLists": string; }; "_condition": { /** @@ -8086,13 +8323,13 @@ export interface Locale extends ILocale { */ "force": string; }; - "_mfm": { + "_cfm": { /** - * MFMチートシート + * CFMチートシート */ "cheatSheet": string; /** - * MFMは、Misskey内の様々な場所で使用できる専用のマークアップ言語です。ここでは、MFMで使用可能な構文一覧が確認できます。 + * CFMは、CherryPick内の様々な場所で使用できる専用のマークアップ言語です。ここでは、CFMで使用可能な構文一覧が確認できます。 */ "intro": string; /** @@ -9623,6 +9860,14 @@ export interface Locale extends ILocale { * 今日誕生日のユーザー */ "birthdayFollowings": string; + /** + * 検索 + */ + "search": string; + /** + * サイコロ + */ + "dice": string; }; "_cw": { /** @@ -9867,6 +10112,18 @@ export interface Locale extends ILocale { * 最大{max}つまでデコレーションを付けられます。 */ "avatarDecorationMax": ParameterizedString<"max">; + /** + * フォローされた時のメッセージ + */ + "followedMessage": string; + /** + * フォローされた時に相手に表示する短いメッセージを設定できます。 + */ + "followedMessageDescription": string; + /** + * フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。 + */ + "followedMessageDescriptionForLockedAccount": string; }; "_exportOrImport": { /** @@ -10407,6 +10664,10 @@ export interface Locale extends ILocale { * 通知の履歴をリセットする */ "flushNotification": string; + /** + * {x}のエクスポートが完了しました + */ + "exportOfXCompleted": ParameterizedString<"x">; "_types": { /** * すべて @@ -10464,6 +10725,14 @@ export interface Locale extends ILocale { * 実績の獲得 */ "achievementEarned": string; + /** + * エクスポートが完了した + */ + "exportCompleted": string; + /** + * 通知のテスト + */ + "test": string; /** * 連携アプリからの通知 */ @@ -10711,6 +10980,10 @@ export interface Locale extends ILocale { * Webhookを削除しますか? */ "deleteConfirm": string; + /** + * スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。 + */ + "testRemarks": string; }; "_abuseReport": { "_notificationRecipient": { @@ -11157,7 +11430,7 @@ export interface Locale extends ILocale { */ "title": string; /** - * MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。 + * CFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。 */ "description": string; }; @@ -11345,6 +11618,10 @@ export interface Locale extends ILocale { * 盤面に行・列番号を表示 */ "showBoardLabels": string; + /** + * 相手のリアクションを表示 + */ + "showReaction": string; /** * 石をアイコンにする */ @@ -11446,6 +11723,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; + }; "_abuse": { "_resolver": { /** @@ -11536,6 +11867,80 @@ export interface Locale extends ILocale { */ "noResizeCompressLossy": string; }; + "_externalNavigationWarning": { + /** + * 外部サイトに移動します + */ + "title": string; + /** + * {host}を離れて外部サイトに移動します + */ + "description": ParameterizedString<"host">; + /** + * このデバイスで今後このドメインを信頼する + */ + "trustThisDomain": string; + }; + "_altWarning": { + /** + * ファイルに代替テキストが設定されていません。 + */ + "noAltWarning": string; + /** + * この設定は「設定 - アピアランス」で変更できます。 + */ + "noAltWarningDescription": string; + }; + "_dice": { + /** + * サイコロを振る + */ + "rollDice": string; + /** + * サイコロの数 + */ + "diceCount": string; + /** + * サイコロの面数 + */ + "diceFaces": string; + }; + "_scheduledNoteDelete": { + /** + * 期限 + */ + "expiration": string; + /** + * 日時指定 + */ + "at": string; + /** + * 経過指定 + */ + "after": string; + /** + * 期日 + */ + "deadlineDate": string; + /** + * 時間 + */ + "deadlineTime": string; + /** + * 期間 + */ + "duration": string; + }; + "_getQRCode": { + /** + * QRコードをスキャンする + */ + "title": string; + /** + * 以下のQRコードをスキャンまたは共有できます。 + */ + "description": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/it-IT.yml b/locales/it-IT.yml index a80b32c88e..760df2809a 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! CherryPick è 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 CherryPick." 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" @@ -513,15 +520,18 @@ groupInvited: "Invitat@ al gruppo" 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" youHaveNoGroups: "Nessun gruppo" joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono." showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mouse" showReactionsCount: "Visualizza il numero di reazioni su una nota" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" -enableAdvancedMfm: "Attiva MFM avanzati" -enableAnimatedMfm: "Attiva MFM animati" +enableAdvancedMfm: "Attiva CFM avanzati" +enableAnimatedMfm: "Attiva CFM animati" doing: "In corso..." category: "Categoria" tags: "Tag" @@ -571,7 +581,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" @@ -598,6 +608,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 CherryPick." +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" @@ -1120,6 +1132,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." @@ -1130,6 +1144,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" @@ -1232,7 +1249,7 @@ overwriteContentConfirm: "Vuoi davvero sostituire l'attuale contenuto?" seasonalScreenEffect: "Schermate in base alla stagione" decorate: "Decora" addMfmFunction: "Aggiungi decorazioni" -enableQuickAddMfmFunction: "Attiva il selettore di funzioni MFM" +enableQuickAddMfmFunction: "Attiva il selettore di funzioni CFM" bubbleGame: "Bubble Game" sfx: "Effetti sonori" soundWillBePlayed: "Con musica ed effetti sonori" @@ -1264,6 +1281,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" @@ -1318,7 +1349,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?" @@ -1328,13 +1359,13 @@ _initialTutorial: _note: title: "Cosa sono le Note?" description: "Gli status su CherryPick 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." @@ -1347,7 +1378,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 CherryPick, hai a disposizione varie opzioni. Il modulo di invio è simile a questo." @@ -1398,6 +1429,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: @@ -1734,6 +1766,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" @@ -1853,9 +1890,9 @@ _displayOfSensitiveMedia: respect: "Nascondere i media espliciti" ignore: "Non nascondere i media espliciti" force: "Nascondi tutti i media" -_mfm: - cheatSheet: "Bigliettino MFM" - intro: "MFM è un linguaggio Markdown particolare che si può usare in diverse parti di Misskey. Qui puoi visualizzare a colpo d'occhio tutta la sintassi MFM utile." +_cfm: + cheatSheet: "Bigliettino CFM" + intro: "CFM è un linguaggio Markdown particolare che si può usare in diverse parti di CherryPick. Qui puoi visualizzare a colpo d'occhio tutta la sintassi CFM utile." dummy: "Il Fediverso si espande con CherryPick" mention: "Menzioni" mentionDescription: "Si può menzionare un utente specifico digitando il suo nome utente subito dopo il segno @." @@ -2039,6 +2076,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 CherryPick. Vuoi continuare lo stesso?" + driveFileError: "Impossibile caricare l'audio. Si prega di modificare le impostazioni" _ago: future: "Futuro" justNow: "Adesso" @@ -2389,6 +2427,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" @@ -2435,6 +2474,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" @@ -2450,6 +2490,8 @@ _notification: groupInvited: "Invito a un gruppo" roleAssigned: "Ruolo concesso" achievementEarned: "Risultato raggiunto" + exportCompleted: "Esportazione completata" + test: "Prova la notifica" app: "Notifiche da applicazioni" _actions: followBack: "Segui" @@ -2501,6 +2543,7 @@ _webhookSettings: modifyWebhook: "Modifica Webhook" name: "Nome" secret: "Segreto" + trigger: "Trigger" active: "Attivo" _events: follow: "Quando segui un profilo" @@ -2513,7 +2556,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" @@ -2572,6 +2617,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" @@ -2703,3 +2752,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 d4aebe8707..1a04e72f88 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1,5 +1,30 @@ _lang_: "日本語" +autoLoadMoreReplies: "返信を自動でもっと見る" +autoLoadMoreConversation: "会話を自動でもっと見る" +useAutoTranslate: "自動翻訳" +useAutoTranslateDescription: "自動翻訳機能を有効にすると、タイムラインのすべてのノートが自動的に翻訳され、これにより翻訳サービス提供者が設定したAPI制限ポリシーにより、翻訳機能を一時的に使用できなくなる可能性があります。\n\nそれでも続けましょうか?" +cantUseAutoTranslateDescription: "サーバー管理者が自動翻訳を使用できないように設定しました。\n自動翻訳を使用するには、サーバー管理者にお問い合わせください。" +cantUseAutoTranslateCaption: "有効にすると、再利用できるときに自動翻訳を適用します。" +widgets: "ウィジェット" +postNote: "ノートを作成" +bottomNavbar: "下のナビゲーションバー" +bottomNavbarDescription: "この設定は、モバイル環境でのみ使用できます。" +scheduledNoteDelete: "ノートの削除を予約" +getQRCode: "QRコードを取得" +customSplashText: "カスタムスプラッシュテキスト" +customSplashTextDescription: "ロード画面に表示されるテキストを設定します。改行で区切って複数設定できます。" +showNoAltWarning: "キャプション未設定案内" +showNoAltWarningDescription: "画像に代替テキストが設定されていない場合に警告を表示する" +filesGridLayoutInUserPage: "メディアタブをグリッドレイアウトに変更" +filesGridLayoutInUserPageDescription: "この設定をオンにすると、ユーザーページのメディアタブがアルバム形式で表示されます。\nオフにすると、元のノートのタイムラインに変更されます。" +showReplyTargetNoteInSemiTransparent: "返信対象ノートを半透明に表示" +noteFooterButton: "ノートにアクションボタンを表示" +collapseReplies: "返信のリノートのスマート省略" +collapseRepliesDescription: "返信で作成されたノートをたたんで表示します。\nリアクションしたノートは影響を受けません。" +repliedBy: "{user}が返信を作成しました" +collapseLongNoteContent: "内容の長いノートを省略して表示" +alwaysShowCw: "「内容を隠す」で設定した内容を常に表示する" forceRenoteVisibilitySelector: "リノートの公開範囲を指定" cherrypickLabs: "CherryPick研究室" cherrypickLabsDescription: "まだ開発中の機能を試してみませんか。一部の機能はちゃんと動かないかもしれません。" @@ -63,10 +88,6 @@ displayBanner: "バナー画像の表示" requireRefresh: "ページの更新が必要なとき" performanceWarning: "リソースを多く使用するため、デバイスの温度が高くなり、バッテリーの消耗が速くなる可能性があります" photosensitiveSeizuresWarning: "光敏感性発作を起こす可能性があります" -friendlyEnableNotifications: "通知領域を有効化" -friendlyDisableNotifications: "通知領域を無効化" -friendlyEnableWidgets: "ウィジェット領域を有効化" -friendlyDisableWidgets: "ウィジェット領域を無効化" useBoldFont: "文字を太くする" newNoteReceivedNotification: "新しいノート通知を表示するとき" disableRightClick: "右クリックを禁止" @@ -316,6 +337,8 @@ silencedInstances: "サイレンスしたサーバー" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。" mediaSilencedInstances: "メディアサイレンスしたサーバー" mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。" +federationAllowedHosts: "連合を許可するサーバー" +federationAllowedHostsDescription: "連合を許可するサーバーのホストを改行で区切って設定します。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -415,6 +438,7 @@ renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" folder: "フォルダー" addFile: "ファイルを追加" +showFile: "ファイルを表示" emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" unableToDelete: "削除できません" @@ -600,17 +624,20 @@ groupInvited: "グループに招待されました" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示しない" +menuStyle: "メニューのスタイル" +style: "スタイル" +drawer: "ドロワー" +popup: "ポップアップ" youHaveNoGroups: "グループがありません" joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループを作成してください。" showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" showReactionsCount: "ノートのリアクション数を表示する" noHistory: "履歴はありません" signinHistory: "ログイン履歴" -enableAdvancedMfm: "高度なMFMを有効にする" -enableAdvancedMfmDescription: "有効にすると、動きのあるMFMのようなさまざまなMFM機能が使用できます。" -enableAnimatedMfm: "動きのあるMFMを有効にする" -enableAnimatedMfmDescription: "有効にすると、MFM文法または絵文字を使用するテキストが動きます。" +enableAdvancedMfm: "高度なCFMを有効にする" +enableAdvancedMfmDescription: "有効にすると、動きのあるCFMのようなさまざまなCFM機能が使用できます。" +enableAnimatedMfm: "動きのあるCFMを有効にする" +enableAnimatedMfmDescription: "有効にすると、CFM文法または絵文字を使用するテキストが動きます。" doing: "やっています" category: "カテゴリ" tags: "タグ" @@ -692,6 +719,8 @@ ascendingOrder: "昇順" descendingOrder: "降順" scratchpad: "スクラッチパッド" scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。CherryPickと対話するコードの記述、実行、結果の確認ができます。" +uiInspector: "UIインスペクター" +uiInspectorDescription: "メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。" output: "出力" script: "スクリプト" disablePagesScript: "Pagesのスクリプトを無効にする" @@ -877,7 +906,7 @@ left: "左" center: "中央" wide: "広い" narrow: "狭い" -reloadToApplySetting: "設定はページリロード後に反映されます。今すぐリロードしますか?" +reloadToApplySetting: "設定はページリロード後に反映されます。" reloadToApplySetting2: "設定はページリロード後に反映されます。" needReloadToApply: "反映には再起動が必要です。" showTitlebar: "タイトルバーを表示する" @@ -1144,7 +1173,7 @@ thisPostMayBeAnnoyingCancel: "やめる" thisPostMayBeAnnoyingIgnore: "このまま投稿" collapseRenotes: "リノートのスマート省略" collapseRenotesDescription: "リアクションやリノートをしたことがあるノートをたたんで表示します。" -collapseDefault: "特定のMFM構文を含むノートを省略して表示" +collapseDefault: "特定のCFM構文を含むノートを省略して表示" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" copyErrorInfo: "エラー情報をコピー" @@ -1341,7 +1370,7 @@ overwriteContentConfirm: "現在の内容に上書きされますがよろしい seasonalScreenEffect: "季節に応じた画面の演出" decorate: "デコる" addMfmFunction: "装飾を追加" -enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" +enableQuickAddMfmFunction: "高度なCFMのピッカーを表示する" bubbleGame: "バブルゲーム" sfx: "効果音" soundWillBePlayed: "サウンドが再生されます" @@ -1375,12 +1404,28 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示 sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?" createdLists: "作成したリスト" createdAntennas: "作成したアンテナ" +fromX: "{x}から" +genEmbedCode: "埋め込みコードを生成" +noteOfThisUser: "このユーザーのノート一覧" +clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" +performance: "パフォーマンス" +modified: "変更あり" +discard: "破棄" +thereAreNChanges: "{n}件の変更があります" +signinWithPasskey: "パスキーでログイン" +unknownWebAuthnKey: "登録されていないパスキーです。" +passkeyVerificationFailed: "パスキーの検証に失敗しました。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" +messageToFollower: "フォロワーへのメッセージ" showUnreadNotificationsCount: "未読の通知の数を表示する" showCatOnly: "キャット付きのみ" additionalPermissionsForFlash: "Playへの追加許可" thisFlashRequiresTheFollowingPermissions: "このPlayは以下の権限を要求しています" doYouWantToAllowThisPlayToAccessYourAccount: "このPlayによるアカウントへのアクセスを許可しますか?" translateProfile: "プロフィールを翻訳する" +trustedLinkUrlPatterns: "外部サイトへのリンク警告 除外リスト" +trustedLinkUrlPatternsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。ドメイン名だけ書くと後方一致になります。" +open: "開く" _nsfwOpenBehavior: click: "タップして開く" @@ -1479,9 +1524,13 @@ _cherrypick: mobileHeaderChange: "モバイル環境でヘッダーのデザインを変更" renameTheButtonInPostFormToNya: "ノート作成画面の「ノート」ボタンを「にゃ!」に変更する" renameTheButtonInPostFormToNyaDescription: "にゃあにゃんにゃんにゃんにゃにゃん?" + enableWidgetsArea: "ウィジェット領域を有効化" + disableWidgetsArea: "ウィジェット領域を無効化" + friendlyUiEnableNotificationsArea: "通知領域を有効化" + friendlyUiDisableNotificationsArea: "通知領域を無効化" enableLongPressOpenAccountMenu: "長押しでアカウントメニューを開く" enableLongPressOpenAccountMenuDescription: "画面下部のタイムラインタブを長押しして開くことができます。" - friendlyShowAvatarDecorationsInNavBtn: "フローティングボタンにアイコンのデコレーションを表示" + friendlyUiShowAvatarDecorationsInNavBtn: "フローティングボタンにアイコンのデコレーションを表示" _bannerDisplay: all: "全て" @@ -1503,7 +1552,7 @@ _initialAccountSetting: privacySetting: "プライバシー設定" fontSizeSetting: "フォントサイズ設定" blurEffectsSetting: "ぼかし効果設定" - mfmAndAnimatedImagesSetting: "MFMとアニメーション画像設定" + mfmAndAnimatedImagesSetting: "CFMとアニメーション画像設定" theseSettingsCanEditLater: "これらの設定は後から変更できます。" youCanEditMoreSettingsInSettingsPageLater: "この他にも様々な設定を「設定」ページから行えます。ぜひ後で確認してみてください。" followUsers: "タイムラインを構築するため、気になるユーザーをフォローしてみましょう。" @@ -1629,6 +1678,7 @@ _serverSettings: fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" fanoutTimelineDbFallback: "データベースへのフォールバック" fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" + reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" @@ -1968,7 +2018,14 @@ _role: canHideAds: "広告の非表示" canSearchNotes: "ノート検索の利用" canUseTranslator: "翻訳機能の利用" + canUseAutoTranslate: "自動翻訳機能の利用" + canUseAutoTranslateDescription: "自動翻訳機能を有効にしたユーザーは、タイムラインのすべてのノートが自動的に翻訳され、これにより翻訳サービス提供者が設定したAPI制限に非常に早く到達し、翻訳機能を一時的に使用できなくなる可能性があります。\nこれは、サーバー内のすべてのユーザーがAPIを一時的に使用できなくなる可能性があることを意味します。\nまた、翻訳サービスの提供者によっては、APIの使用による料金が過度に発生する可能性があります。\n\nそれでも続けましょうか?" avatarDecorationLimit: "アイコンデコレーションの最大取付個数" + canImportAntennas: "アンテナのインポートを許可" + canImportBlocking: "ブロックのインポートを許可" + canImportFollowing: "フォローのインポートを許可" + canImportMuting: "ミュートのインポートを許可" + canImportUserLists: "リストのインポートを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -2116,9 +2173,9 @@ _displayOfSensitiveMedia: ignore: "センシティブ設定されたメディアを隠さない" force: "常にメディアを隠す" -_mfm: - cheatSheet: "MFMチートシート" - intro: "MFMは、Misskey内の様々な場所で使用できる専用のマークアップ言語です。ここでは、MFMで使用可能な構文一覧が確認できます。" +_cfm: + cheatSheet: "CFMチートシート" + intro: "CFMは、CherryPick内の様々な場所で使用できる専用のマークアップ言語です。ここでは、CFMで使用可能な構文一覧が確認できます。" dummy: "CherryPickでFediverseの世界が広がります" mention: "メンション" mentionDescription: "アットマーク + ユーザー名で、特定のユーザーを示すことができます。" @@ -2530,6 +2587,8 @@ _widgets: chooseList: "リストを選択" clicker: "クリッカー" birthdayFollowings: "今日誕生日のユーザー" + search: "検索" + dice: "サイコロ" _cw: hide: "隠す" @@ -2599,6 +2658,9 @@ _profile: changeBanner: "バナー画像を変更" verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。" avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。" + followedMessage: "フォローされた時のメッセージ" + followedMessageDescription: "フォローされた時に相手に表示する短いメッセージを設定できます。" + followedMessageDescriptionForLockedAccount: "フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。" _exportOrImport: allNotes: "全てのノート" @@ -2748,6 +2810,7 @@ _notification: renotedBySomeUsers: "{n}人がリノートしました" followedBySomeUsers: "{n}人にフォローされました" flushNotification: "通知の履歴をリセットする" + exportOfXCompleted: "{x}のエクスポートが完了しました" _types: all: "すべて" @@ -2764,6 +2827,8 @@ _notification: groupInvited: "グループに招待された" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + exportCompleted: "エクスポートが完了した" + test: "通知のテスト" app: "連携アプリからの通知" _actions: @@ -2837,6 +2902,7 @@ _webhookSettings: abuseReportResolved: "ユーザーからの通報を処理したとき" userCreated: "ユーザーが作成されたとき" deleteConfirm: "Webhookを削除しますか?" + testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" _abuseReport: _notificationRecipient: @@ -2966,7 +3032,7 @@ _dataSaver: description: "URLプレビューのサムネイル画像が読み込まれなくなります。" _code: title: "コードハイライトを非表示" - description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。" + description: "CFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。" _hemisphere: N: "北半球" @@ -3016,6 +3082,7 @@ _reversi: allowIrregularRules: "変則許可 (完全フリー)" disallowIrregularRules: "変則なし" showBoardLabels: "盤面に行・列番号を表示" + showReaction: "相手のリアクションを表示" useAvatarAsStone: "石をアイコンにする" _offlineScreen: @@ -3048,6 +3115,21 @@ _contextMenu: appWithShift: "Shiftキーでアプリケーション" native: "ブラウザのUI" +_embedCodeGen: + title: "埋め込みコードをカスタマイズ" + header: "ヘッダーを表示" + autoload: "自動で続きを読み込む(非推奨)" + maxHeight: "高さの最大値" + maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。" + maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。" + previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。" + rounded: "角丸にする" + border: "外枠に枠線をつける" + applyToPreview: "プレビューに反映" + generateCode: "埋め込みコードを作成" + codeGenerated: "コードが生成されました" + codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" + _abuse: _resolver: 1hour: "一時間" @@ -3073,3 +3155,29 @@ _imageCompressionMode: noResizeCompress: "縮小せず再圧縮する" resizeCompressLossy: "縮小して非可逆圧縮する" noResizeCompressLossy: "縮小せず非可逆圧縮する" + +_externalNavigationWarning: + title: "外部サイトに移動します" + description: "{host}を離れて外部サイトに移動します" + trustThisDomain: "このデバイスで今後このドメインを信頼する" + +_altWarning: + noAltWarning: "ファイルに代替テキストが設定されていません。" + noAltWarningDescription: "この設定は「設定 - アピアランス」で変更できます。" + +_dice: + rollDice: "サイコロを振る" + diceCount: "サイコロの数" + diceFaces: "サイコロの面数" + +_scheduledNoteDelete: + expiration: "期限" + at: "日時指定" + after: "経過指定" + deadlineDate: "期日" + deadlineTime: "時間" + duration: "期間" + +_getQRCode: + title: "QRコードをスキャンする" + description: "以下のQRコードをスキャンまたは共有できます。" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 25841f8d47..59c8f5e778 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -519,15 +519,14 @@ groupInvited: "グループに招待されとるで" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示せえへん" youHaveNoGroups: "グループがあらへんねぇ。" joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループ作ってからやってな" showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" showReactionsCount: "ノートのリアクション数を表示する" noHistory: "履歴はないわ。" signinHistory: "ログイン履歴" -enableAdvancedMfm: "ややこしいMFMもありにする" -enableAnimatedMfm: "動きがやかましいMFMも許したる" +enableAdvancedMfm: "ややこしいCFMもありにする" +enableAnimatedMfm: "動きがやかましいCFMも許したる" doing: "やっとるがな" category: "カテゴリ" tags: "タグ" @@ -1243,7 +1242,7 @@ overwriteContentConfirm: "今の内容に上書きされるけどいい?" seasonalScreenEffect: "季節にあった画面の動き" decorate: "デコる" addMfmFunction: "装飾つける" -enableQuickAddMfmFunction: "ややこしいMFMのピッカーを出す" +enableQuickAddMfmFunction: "ややこしいCFMのピッカーを出す" bubbleGame: "バブルゲーム" sfx: "効果音" soundWillBePlayed: "サウンドが再生されるで" @@ -1866,9 +1865,9 @@ _displayOfSensitiveMedia: respect: "きわどいのは見とうない" ignore: "きわどいのも見たい" force: "常にメディアを隠すで" -_mfm: - cheatSheet: "MFMチートシート" - intro: "MFMは、Misskey内の色んな所で使える専用のマークアップ言語やで。このページでMFMで使える構文一覧が確認できるで。" +_cfm: + cheatSheet: "CFMチートシート" + intro: "CFMは、CherryPick内の色んな所で使える専用のマークアップ言語やで。このページでCFMで使える構文一覧が確認できるで。" dummy: "CherryPickでFediverseの世界が広がります" mention: "メンション" mentionDescription: "アットマーク + ユーザー名で、特定のユーザーを示すことができるで。" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index b215f89293..a178d481f3 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -476,12 +476,11 @@ uiLanguage: "UI 표시 언어" aboutX: "{x}에 대해서" emojiStyle: "이모지 모양" native: "기본" -disableDrawer: "드로어 메뉴 쓰지 않기" showNoteActionsOnlyHover: "마우스 올맀을 때만 노트 액션 버턴 보이기" noHistory: "기록이 없십니다" signinHistory: "로그인 기록" -enableAdvancedMfm: "복잡한 MFM 키기" -enableAnimatedMfm: "정신사나운 MFM 키기" +enableAdvancedMfm: "복잡한 CFM 키기" +enableAnimatedMfm: "정신사나운 CFM 키기" doing: "잠만예" category: "카테고리" tags: "태그" @@ -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 3dc14a532c..e100d158c3 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1,5 +1,30 @@ --- _lang_: "한국어" +autoLoadMoreReplies: "답글을 자동으로 더 보기" +autoLoadMoreConversation: "대화를 자동으로 더 보기" +useAutoTranslate: "자동 번역" +useAutoTranslateDescription: "자동 번역 기능을 활성화하면 타임라인의 모든 노트가 자동으로 번역되며, 이로 인해 번역 서비스 제공자가 설정한 API 제한 정책에 의해 번역 기능을 일시적으로 사용하지 못하게 될 가능성이 있어요.\n\n그래도 활성화 하시겠어요?" +cantUseAutoTranslateDescription: "서버 관리자가 자동 번역을 사용할 수 없도록 설정했어요.\n자동 번역을 사용하려면 서버 관리자에게 문의해 주세요." +cantUseAutoTranslateCaption: "활성화하면 다시 사용할 수 있을 때 자동 번역을 적용해요." +widgets: "위젯" +postNote: "노트 작성" +bottomNavbar: "하단 내비게이션 바" +bottomNavbarDescription: "이 설정은 모바일 환경에서만 사용할 수 있어요." +scheduledNoteDelete: "노트 삭제 예약" +getQRCode: "QR 코드 생성" +customSplashText: "사용자 정의 스플래시 텍스트" +customSplashTextDescription: "스플래시 화면에 표시되는 텍스트를 설정해요. 줄바꿈으로 구분해 설정할 수 있어요." +showNoAltWarning: "캡션 미설정 안내" +showNoAltWarningDescription: "이미지에 캡션이 설정되어 있지 않으면 경고를 표시해요" +filesGridLayoutInUserPage: "미디어 탭을 그리드 레이아웃으로 변경" +filesGridLayoutInUserPageDescription: "이 설정을 켜면 사용자 페이지의 미디어 탭이 앨범 형식으로 보여져요.\n끄면 원래의 노트 타임라인으로 변경돼요." +showReplyTargetNoteInSemiTransparent: "답글 대상 노트를 반투명하게 표시" +noteFooterButton: "노트 동작 버튼" +collapseReplies: "답글로 작성된 노트 간략화하기" +collapseRepliesDescription: "답글로 작성된 노트를 접어서 표시해요.\n리액션한 노트는 영향을 받지 않아요." +repliedBy: "{user}님이 답글을 작성했어요" +collapseLongNoteContent: "내용이 긴 노트 간략화하기" +alwaysShowCw: "'내용 가리기'로 설정한 내용을 항상 보이기" forceRenoteVisibilitySelector: "리노트 공개 범위 지정" cherrypickLabs: "CherryPick 실험실" cherrypickLabsDescription: "개발 중인 기능을 사용해 보시겠어요? 아직 개발 중인 기능이므로 제대로 작동하지 않을 수 있어요." @@ -38,15 +63,15 @@ showFixedPostFormInRepliesDescription: "데스크톱과 태블릿 환경에서 renoteQuoteButtonSeparation: "리노트와 인용 버튼을 분리해서 표시하기" showReplyInNotification: "알림에서 답글이 달린 노트의 상위 노트 표시하기" infoButtonForNoteActions: "노트에 자세히 버튼 표시" -infoButtonForNoteActionsDescription: "'노트 액션 버튼을 마우스를 올렸을 때에만 표시' 기능을 켰을 때만 적용돼요." +infoButtonForNoteActionsDescription: "'노트 동작 버튼을 마우스를 올렸을 때에만 표시' 기능을 켰을 때만 적용돼요." disabledServerMachineStats: "'서버의 머신 사양을 공개하기' 설정이 꺼져 있습니다.\n서버 통계를 보려면 '제어판 - 기타'에서 '서버의 머신 사양을 공개하기' 설정을 활성화하세요." replayUserSetupDialog: "초기 설정 다시 보기" replayTutorial: "튜토리얼 다시 보기" nya: "냥!" addSingle: "하나만 추가" addMultiple: "여러 개 추가" -showSubNoteFooterButton: "서브 노트에 액션 버튼 표시" -showSubNoteFooterButtonDescription: "이 설정을 활성화하면 답글이 달린 노트의 상위 노트에 액션 버튼을 표시해요." +showSubNoteFooterButton: "서브 노트에 동작 버튼 표시" +showSubNoteFooterButtonDescription: "이 설정을 활성화하면 답글이 달린 노트의 상위 노트에 동작 버튼을 표시해요." alreadyFollowed: "팔로우 했어요!" enableMarkByDate: "노트 시간을 일자로 표시" renoteConfirm: "리노트 할까요?" @@ -63,10 +88,6 @@ displayBanner: "배너 이미지 표시" requireRefresh: "페이지 새로 고침이 필요할 때" performanceWarning: "리소스를 많이 사용하므로, 디바이스의 온도가 높아지고 배터리의 소모가 빨라질 수 있어요" photosensitiveSeizuresWarning: "광과민성 발작을 일으킬 수 있어요" -friendlyEnableNotifications: "알림 영역 활성화" -friendlyDisableNotifications: "알림 영역 비활성화" -friendlyEnableWidgets: "위젯 영역 활성화" -friendlyDisableWidgets: "위젯 영역 비활성화" useBoldFont: "볼드체 텍스트" newNoteReceivedNotification: "새 노트 알림을 표시할 때" disableRightClick: "우클릭 방지" @@ -129,7 +150,7 @@ copyAndEditConfirm: "이 노트를 복사하고 편집할까요? 노트에 포 addToList: "리스트에 추가" addToAntenna: "안테나에 추가" sendMessage: "메시지 보내기" -copyRSS: "RSS 주소 복사" +copyRSS: "RSS 복사" copyUsername: "사용자 이름 복사" copyUserId: "사용자 ID 복사" copyNoteId: "노트 ID 복사" @@ -137,6 +158,7 @@ copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" searchUser: "사용자 검색" +searchThisUsersNotes: "사용자 노트 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -234,6 +256,7 @@ editList: "리스트 편집" selectChannel: "채널 선택" selectAntenna: "안테나 선택" editAntenna: "안테나 편집" +createAntenna: "안테나 만들기" selectWidget: "위젯 선택" editWidgets: "위젯 편집" editWidgetsExit: "편집 종료" @@ -244,11 +267,11 @@ emojiName: "이모지 이름" emojiUrl: "이모지 URL" addEmoji: "이모지 추가" settingGuide: "추천 설정" -cacheRemoteFiles: "리모트 파일을 캐시" -cacheRemoteFilesDescription: "이 설정을 활성화하면 리모트 파일을 이 서버의 저장소에 캐시해요. 그에 따라 미디어가 표시되는 속도가 증가하지만, 서버의 저장 공간을 많이 사용할 수도 있어요. 리모트 사용자의 미디어를 얼마나 보관할 지는 '역할'의 '드라이브 용량 제한'에 따라 결정되며, 정해진 용량을 초과할 경우, 오래된 파일부터 차례대로 삭제하고 링크로 전환돼요. \n비활성화하면 리모트 파일을 직접 링크하며, 이미지 썸네일 생성 및 사용자 프라이버시 보호를 위해 default.yml에서 proxyRemoteFiles를 true로 설정하는 것을 권장해요." +cacheRemoteFiles: "원격 서버 파일을 캐시" +cacheRemoteFilesDescription: "이 설정을 활성화하면 원격 서버의 파일을 이 서버의 저장소에 캐시해요. 그에 따라 미디어가 표시되는 속도가 증가하지만, 서버의 저장 공간을 많이 사용할 수도 있어요. 원격 사용자의 미디어를 얼마나 보관할 지는 '역할'의 '드라이브 용량 제한'에 따라 결정되며, 정해진 용량을 초과할 경우, 오래된 파일부터 차례대로 삭제하고 링크로 전환돼요. \n비활성화하면 원격 서버의 파일을 직접 링크하며, 이미지 썸네일 생성 및 사용자 프라이버시 보호를 위해 default.yml에서 proxyRemoteFiles를 true로 설정하는 것을 권장해요." youCanCleanRemoteFilesCache: "파일 관리 화면의 🗑️ 버튼을 눌러 모든 캐시를 삭제할 수 있어요." -cacheRemoteSensitiveFiles: "리모트의 민감한 파일을 캐시" -cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 리모트의 민감한 파일은 캐시하지 않고 리모트에서 직접 가져오도록 설정해요." +cacheRemoteSensitiveFiles: "원격 서버의 민감한 파일을 캐시" +cacheRemoteSensitiveFilesDescription: "이 설정을 비활성화하면 원격 서버의 민감한 파일은 캐시하지 않고 원격 서버에서 직접 가져오도록 설정해요." flagAsBot: "삐릭, 삐리리릭? 저는 봇입니다." flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 주세요. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 봇 끼리의 무한 연쇄 반응을 회피하거나, 이 계정의 시스템 상에서의 취급이 봇 운영에 최적화되는 등의 변화가 생겨요" flagAsCat: "나는 고양이다냥" @@ -274,6 +297,7 @@ followConfirm: "{name}님을 팔로우 하시겠어요?" proxyAccount: "프록시 계정" proxyAccountDescription: "프록시 계정은 사용자의 리모트 팔로우를 대행하는 계정이에요. 예를 들면, 리모트 사용자를 리스트에 넣었을 때, 리스트에 들어간 사용자를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에 대신 프록시 계정이 해당 사용자를 팔로우해서 문제를 해결해요." host: "호스트" +selectSelf: "나를 선택" selectUser: "사용자 선택" recipient: "수신인" annotation: "내용에 대한 주석" @@ -289,6 +313,7 @@ perDay: "1일마다" stopActivityDelivery: "액티비티 보내지 않기" blockThisInstance: "이 서버를 차단" silenceThisInstance: "서버를 사일런스" +mediaSilenceThisInstance: "서버의 미디어를 사일런스" operations: "작업" software: "소프트웨어" version: "버전" @@ -305,11 +330,15 @@ clearQueue: "대기열 비우기" clearQueueConfirmTitle: "대기열을 비울까요?" clearQueueConfirmText: "아직 대기열에 남아 있는 노트는 연합우주에 전송되지 않게 돼요. 보통은 이 작업이 필요하지 않아요." clearCachedFiles: "캐시 비우기" -clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제할까요?" +clearCachedFilesConfirm: "캐시된 원격 서버 파일을 모두 삭제할까요?" blockedInstances: "차단된 서버" blockedInstancesDescription: "차단하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정해요. 차단된 서버는 이 서버와 통신할 수 없게 돼요." silencedInstances: "사일런스한 서버" silencedInstancesDescription: "사일런스하려는 서버의 호스트명을 한 줄에 하나씩 입력해 주세요. 사일런스된 서버에 소속된 사용자는 모두 '사일런스'된 상태로 취급되며, 이 서버로부터의 팔로우가 프로필 설정과 무관하게 승인제로 변경되고, 팔로워가 아닌 로컬 사용자에게는 멘션할 수 없게 돼요. 정지된 서버에는 적용되지 않아요." +mediaSilencedInstances: "미디어를 사일런스한 서버" +mediaSilencedInstancesDescription: "미디어를 사일런스 하려는 서버의 호스트를 한 줄에 하나씩 입력해 주세요. 미디어가 사일런스된 서버의 사용자가 업로드한 파일은 모두 민감한 미디어로 처리되고 커스텀 이모지를 사용할 수 없게 돼요. 또한, 차단한 인스턴스에는 적용되지 않아요." +federationAllowedHosts: "연합을 허용하는 서버" +federationAllowedHostsDescription: "연합을 허용하는 서버의 호스트를 줄바꿈으로 구분해서 한 줄에 하나씩 입력해 주세요." muteAndBlock: "뮤트 및 차단" mutedUsers: "뮤트한 사용자" blockedUsers: "차단한 사용자" @@ -379,7 +408,7 @@ basicNotesBeforeCreateAccount: "기본적인 주의사항" termsOfService: "이용 약관" start: "시작하기" home: "홈" -remoteUserCaution: "리모트 사용자에요! 이 서버와 정보가 일치하지 않을 수 있어요." +remoteUserCaution: "원격 서버의 사용자에요! 이 서버와 정보가 일치하지 않을 수 있어요." activity: "활동" images: "이미지" image: "이미지" @@ -409,6 +438,7 @@ renameFolder: "폴더 이름 바꾸기" deleteFolder: "폴더 삭제" folder: "폴더" addFile: "파일 추가" +showFile: "파일 표시하기" emptyDrive: "드라이브에 아무것도 없어요!" emptyFolder: "폴더에 아무것도 없어요!" unableToDelete: "삭제할 수 없어요!" @@ -453,8 +483,8 @@ disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리 registration: "등록" enableRegistration: "신규 회원가입 활성화" invite: "초대" -driveCapacityPerLocalAccount: "로컬 사용자 한 명당 드라이브 용량" -driveCapacityPerRemoteAccount: "리모트 사용자 한 명당 드라이브 용량" +driveCapacityPerLocalAccount: "로컬 사용자별 드라이브 용량" +driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량" inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" @@ -580,9 +610,9 @@ unavailable: "안타깝지만 사용할 수 없어요.." usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있어요" tooShort: "너무 짧은 것 같아요!" tooLong: "너무 긴 것 같아요!" -weakPassword: "약한 비밀번호" -normalPassword: "좋은 비밀번호" -strongPassword: "강한 비밀번호" +weakPassword: "약한 수준의 비밀번호" +normalPassword: "보통 수준의 비밀번호" +strongPassword: "강력한 수준의 비밀번호" passwordMatched: "똑같아요!" passwordNotMatched: "어라? 비밀번호가 다른 것 같아요" signinWith: "{x}로 로그인" @@ -593,18 +623,21 @@ uiLanguage: "UI 표시 언어" groupInvited: "그룹에 초대됐어요" aboutX: "{x}에 대하여" emojiStyle: "이모지 스타일" -native: "네이티브" -disableDrawer: "드로어 메뉴를 사용하지 않기" +native: "기본" +menuStyle: "메뉴 스타일" +style: "스타일" +drawer: "서랍" +popup: "팝업" youHaveNoGroups: "그룹이 없어요" joinOrCreateGroup: "다른 그룹의 초대를 받거나, 직접 새 그룹을 만들 수 있어요!" showNoteActionsOnlyHover: "노트에 커서를 올렸을 때에만 노트 동작 버튼 표시" showReactionsCount: "노트의 리액션 수 표시하기" noHistory: "기록이 없어요" signinHistory: "로그인 기록" -enableAdvancedMfm: "고급 MFM 활성화" -enableAdvancedMfmDescription: "활성화하면 움직임이 있는 MFM과 같은 다양한 MFM 기능을 사용할 수 있게 돼요." -enableAnimatedMfm: "움직임이 있는 MFM 활성화" -enableAnimatedMfmDescription: "활성화하면 MFM 문법을 사용한 텍스트나 이모지가 움직이게 돼요." +enableAdvancedMfm: "고급 CFM 활성화" +enableAdvancedMfmDescription: "활성화하면 움직임이 있는 CFM과 같은 다양한 CFM 기능을 사용할 수 있게 돼요." +enableAnimatedMfm: "움직임이 있는 CFM 활성화" +enableAnimatedMfmDescription: "활성화하면 CFM 문법을 사용한 텍스트나 이모지가 움직이게 돼요." doing: "잠시만 기다려 주세요" category: "카테고리" tags: "태그" @@ -685,7 +718,9 @@ sort: "정렬" ascendingOrder: "오름차순" descendingOrder: "내림차순" scratchpad: "스크래치 패드" -scratchpadDescription: "스크래치 패드는 AiScript 의 테스트 환경을 제공해요. CherryPick과 상호 작용하는 코드를 작성하거나 실행 및 결과를 확인할 수 있어요." +scratchpadDescription: "스크래치 패드는 AiScript의 테스트 환경을 제공해요. CherryPick과 상호 작용하는 코드를 작성하거나 실행 및 결과를 확인할 수 있어요." +uiInspector: "UI 인스펙터" +uiInspectorDescription: "메모리에 있는 UI 컴포넌트의 인스턴트 목록을 볼 수 있어요. UI 컴포넌트는 Ui:C: 계열 함수로 만들어져요." output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" @@ -720,7 +755,7 @@ invisibleNote: "비공개 노트" enableInfiniteScroll: "스크롤해서 더 보기" visibility: "공개 범위" poll: "투표" -useCw: "내용 숨기기" +useCw: "내용 가리기" enablePlayer: "플레이어 열기" disablePlayer: "플레이어 닫기" expandTweet: "게시물 확장하기" @@ -802,8 +837,8 @@ abuseReported: "신고를 보냈어요! 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" reporterOrigin: "신고자" -forwardReport: "리모트 서버에도 신고 내용 보내기" -forwardReportIsAnonymous: "리모트 서버에서는 내 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시돼요." +forwardReport: "원격 서버에도 신고 내용 보내기" +forwardReportIsAnonymous: "원격 서버에서는 내 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시돼요." send: "전송" abuseMarkAsResolved: "해결됨으로 표시" openInNewTab: "새 탭에서 열기" @@ -1073,7 +1108,7 @@ slow: "느리게" fast: "빠르게" sensitiveMediaDetection: "민감한 미디어 탐지" localOnly: "로컬에만" -remoteOnly: "리모트만" +remoteOnly: "원격 서버만" failedToUpload: "업로드 실패" cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없어요!" cannotUploadBecauseNoFreeSpace: "드라이브에 용량이 부족해서 업로드할 수 없었어요.." @@ -1138,7 +1173,7 @@ thisPostMayBeAnnoyingCancel: "그만두기" thisPostMayBeAnnoyingIgnore: "이대로 게시" collapseRenotes: "이미 본 리노트를 간략화하기" collapseRenotesDescription: "리액션이나 리노트한 노트를 접어서 표시해요." -collapseDefault: "특정 MFM 구문이 포함된 노트 간략화하기" +collapseDefault: "특정 CFM 구문이 포함된 노트 간략화하기" internalServerError: "내부 서버 오류" internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했어요." copyErrorInfo: "오류 정보 복사" @@ -1154,9 +1189,9 @@ postToTheChannel: "채널에 게시하기" cannotBeChangedLater: "나중에 변경할 수 없어요." reactionAcceptance: "리액션 수신" likeOnly: "좋아요만 받기" -likeOnlyForRemote: "리모트에서는 좋아요만 받기" +likeOnlyForRemote: "원격 서버의 사용자에게는 좋아요만 받기" nonSensitiveOnly: "민감한 이모지를 제외하고 받기" -nonSensitiveOnlyForLocalLikeOnlyForRemote: "민감한 이모지를 제외하고 받기(리모트에서는 좋아요만 받기)" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "민감한 이모지를 제외하고 받기(원격 서버의 사용자에게는 좋아요만 받기)" rolesAssignedToMe: "나에게 할당된 역할" resetPasswordConfirm: "비밀번호를 재설정할까요?" sensitiveWords: "민감한 단어" @@ -1175,8 +1210,8 @@ drivecleaner: "드라이브 정리" retryAllQueuesNow: "모든 큐를 다시 시도" retryAllQueuesConfirmTitle: "지금 다시 시도할까요?" retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있어요!" -enableChartsForRemoteUser: "리모트 사용자 차트 생성" -enableChartsForFederatedInstances: "리모트 서버 차트 생성" +enableChartsForRemoteUser: "원격 서버 사용자 차트 생성" +enableChartsForFederatedInstances: "원격 서버 차트 생성" showClipButtonInNoteFooter: "노트 동작에 클립 버튼 추가" reactionsDisplaySize: "리액션 표시 크기" limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시" @@ -1215,6 +1250,8 @@ preservedUsernames: "예약된 사용자 이름" preservedUsernamesDescription: "예약할 사용자 이름을 한 줄에 하나씩 입력해 주세요. 여기에서 지정한 사용자 이름으로는 계정을 생성할 수 없게 돼요. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않아요." createNoteFromTheFile: "이 파일로 노트 작성" archive: "보관" +archived: "보관됨" +unarchive: "보관 취소" channelArchiveConfirmTitle: "{name} 을(를) 보관할까요?" channelArchiveConfirmDescription: "보관한 채널은 채널 목록과 검색 결과에 표시되지 않으며, 채널에 새로운 노트를 작성할 수 없게 돼요." thisChannelArchived: "이 채널은 보관되었어요." @@ -1225,6 +1262,9 @@ preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구해요. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아니에요." options: "옵션" specifyUser: "사용자 지정" +lookupConfirm: "조회 할까요?" +openTagPageConfirm: "해시태그의 페이지를 열까요?" +specifyHost: "호스트 지정" failedToPreviewUrl: "미리 볼 수 없음" update: "업데이트" rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할" @@ -1321,7 +1361,7 @@ pullDownToRefresh: "당겨서 새로 고침" disableStreamingTimeline: "타임라인 실시간 업데이트 비활성화" useGroupedNotifications: "알림을 묶어서 표시" signupPendingError: "메일 주소 확인중에 문제가 발생했어요. 링크의 유효기간이 지났을 수도 있어요." -cwNotationRequired: "'내용 숨기기'를 체크했을 경우 주석을 작성해야 해요." +cwNotationRequired: "'내용 가리기'를 체크했을 경우 주석을 작성해야 해요." doReaction: "리액션 추가" code: "코드" reloadRequiredToApplySettings: "설정을 반영하려면 페이지를 다시 불러와야 해요." @@ -1330,7 +1370,7 @@ overwriteContentConfirm: "현재 내용을 덮어쓰기 하게 돼요. 그래도 seasonalScreenEffect: "계절에 따른 화면 연출" decorate: "장식하기" addMfmFunction: "장식 추가" -enableQuickAddMfmFunction: "고급 MFM 선택기 표시하기" +enableQuickAddMfmFunction: "고급 CFM 선택기 표시" bubbleGame: "버블 게임" sfx: "효과음" soundWillBePlayed: "사운드가 재생돼요" @@ -1357,16 +1397,34 @@ useNativeUIForVideoAudioPlayer: "미디어 재생 시 브라우저 UI 사용" keepOriginalFilename: "원본 파일 이름 유지" keepOriginalFilenameDescription: "이 설정을 끄면, 파일을 업로드할 때 파일 이름이 무작위 문자열로 자동으로 변경돼요." noDescription: "내용에 대한 설명이 없어요" -alwaysConfirmFollow: "팔로우일 때 항상 확인하기" +alwaysConfirmFollow: "팔로우할 때 한 번 더 묻기" inquiry: "문의하기" tryAgain: "다시 시도해 주세요." confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 한 번 더 확인" +sensitiveMediaRevealConfirm: "이 미디어는 민감한 미디어로 설정되어 있어요. 표시할까요?" +createdLists: "만든 리스트" +createdAntennas: "만든 안테나" +fromX: "{x}부터" +genEmbedCode: "임베디드 코드 만들기" +noteOfThisUser: "이 사용자의 노트 목록" +clipNoteLimitExceeded: "더 이상 이 클립에 노트를 추가 할 수 없어요." +performance: "성능" +modified: "변경된 부분 있음" +discard: "변경 취소" +thereAreNChanges: "{n}건의 변경이 있어요." +signinWithPasskey: "패스키로 로그인" +unknownWebAuthnKey: "등록되지 않은 패스키에요." +passkeyVerificationFailed: "패스키 검증을 실패했어요." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했지만 '비밀번호 없이 로그인하기'가 꺼져 있어요." showUnreadNotificationsCount: "읽지 않은 알림 수 표시" showCatOnly: "고양이만 보기" additionalPermissionsForFlash: "Play에 대한 추가 권한" thisFlashRequiresTheFollowingPermissions: "이 Play는 다음 권한을 요구해요" doYouWantToAllowThisPlayToAccessYourAccount: "이 Play가 계정에 접근하도록 허용할까요?" translateProfile: "프로필 번역하기" +trustedLinkUrlPatterns: "외부 사이트 링크 경고 제외 목록" +trustedLinkUrlPatternsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정할 수 있어요. 슬래시로 둘러싸면 정규 표현식이 돼요. 도메인만 쓰면 후방 탐색으로 작동해요." +open: "열기" _nsfwOpenBehavior: click: "탭하여 열기" doubleClick: "두 번 탭하여 열기" @@ -1451,14 +1509,18 @@ _cherrypick: hide: "모두 숨기기" patch: "패치" patchDescription: "Misskey의 기능을 변경해요." - reactableRemoteReaction: "서버에 리모트 이모지와 이름이 같은 이모지가 있으면 리모트 이모지에도 리액션할 수 있음" + reactableRemoteReaction: "서버에 원격 서버의 이모지와 이름이 같은 이모지가 있으면 원격 서버의 이모지에도 리액션할 수 있음" showFollowingMessageInsteadOfButton: "이미 팔로우한 경우 알림 필드에 팔로우 버튼을 표시하지 않음" mobileHeaderChange: "모바일 환경에서 헤더 디자인을 변경" renameTheButtonInPostFormToNya: "노트 작성 화면의 '노트' 버튼을 '냥!'으로 변경" renameTheButtonInPostFormToNyaDescription: "냐앙냥냥냥냐냥?" + enableWidgetsArea: "위젯 영역 활성화" + disableWidgetsArea: "위젯 영역 비활성화" + friendlyUiEnableNotificationsArea: "알림 영역 활성화" + friendlyUiDisableNotificationsArea: "알림 영역 비활성화" enableLongPressOpenAccountMenu: "길게 눌러 계정 메뉴 열기" enableLongPressOpenAccountMenuDescription: "화면 아래쪽의 타임라인 탭을 길게 눌러 열 수 있어요." - friendlyShowAvatarDecorationsInNavBtn: "플로팅 버튼에 아바타 장식 표시" + friendlyUiShowAvatarDecorationsInNavBtn: "플로팅 버튼에 아바타 장식 표시" _bannerDisplay: all: "전부" topBottom: "상단 및 하단" @@ -1477,7 +1539,7 @@ _initialAccountSetting: privacySetting: "프라이버시 설정" fontSizeSetting: "글자 크기 설정" blurEffectsSetting: "흐림 효과 설정" - mfmAndAnimatedImagesSetting: "MFM 및 움직이는 이미지 설정" + mfmAndAnimatedImagesSetting: "CFM 및 움직이는 이미지 설정" theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있어요." youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 나의 입맛에 맞게 조절할 수 있으니 꼭 확인해 보세요!" followUsers: "관심사가 맞는 사람을 팔로우하여 타임라인을 가꾸어 보세요." @@ -1598,6 +1660,7 @@ _serverSettings: fanoutTimelineDescription: "활성화하면 각종 타임라인을 가져올 때의 성능을 대폭 향상하며, 데이터베이스의 부하를 줄일 수 있어요. 단, Redis의 메모리 사용량이 증가하게 되고, 서버의 메모리 용량이 작거나, 서비스가 불안정해지는 경우 해당 설정을 비활성화해 주세요." fanoutTimelineDbFallback: "데이터베이스 폴백" fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해서는 DB에 추가로 쿼리하는 폴백 처리를 수행해요. 비활성화하면 폴백 처리를 하지 않아 서버의 부하를 줄일 수 있지만, 타임라인을 가져올 수 있는 범위가 한정돼요." + reactionsBufferingDescription: "활성화하면 노트에 리액션할 때의 성능이 대폭 향상되어 DB의 부하를 줄일 수 있지만, Redis의 메모리 사용량이 많아져요." inquiryUrl: "문의처 URL" inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정할 수 있어요." _accountMigration: @@ -1932,13 +1995,20 @@ _role: rateLimitFactor: "요청 빈도 제한" descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화돼요." canHideAds: "광고 숨기기" - canSearchNotes: "노트 검색 이용 가능 여부" - canUseTranslator: "번역 기능 이용 가능 여부" + canSearchNotes: "노트 검색 이용" + canUseTranslator: "번역 기능 이용" + canUseAutoTranslate: "자동 번역 기능 이용" + canUseAutoTranslateDescription: "자동 번역 기능을 활성화한 사용자는 타임라인의 모든 노트가 자동으로 번역되며, 이로 인해 번역 서비스 제공자가 설정한 API 제한에 매우 빠르게 도달해 번역 기능을 일시적으로 사용하지 못하게 될 가능성이 있어요.\n이는 서버 내 모든 사용자가 API를 일시적으로 사용하지 못하게 될 수도 있다는 것을 의미해요.\n또한 번역 서비스 제공자에 따라 API 사용으로 인한 요금이 과도하게 발생할 수 있어요.\n\n그래도 활성화 하시겠어요?" avatarDecorationLimit: "최대로 붙일 수 있는 아바타 장식 개수" + canImportAntennas: "안테나 가져오기 허용" + canImportBlocking: "차단 목록 가져오기 허용" + canImportFollowing: "팔로우 가져오기 허용" + canImportMuting: "뮤트 목록 가져오기 허용" + canImportUserLists: "리스트 목록 가져오기 허용" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" isLocal: "로컬 사용자" - isRemote: "리모트 사용자" + isRemote: "원격 사용자" isCat: "계정을 고양이로 설정한 사용자" isBot: "계정을 봇으로 설정한 사용자" isSuspended: "정지된 사용자" @@ -2063,12 +2133,12 @@ _aboutMisskey: relayServer: "릴레이 서버" community: "커뮤니티" _displayOfSensitiveMedia: - respect: "민감한 콘텐츠로 표시된 미디어 숨기기" + respect: "민감한 콘텐츠로 표시된 미디어 가리기" ignore: "민감한 콘텐츠로 표시된 미디어 보이기" - force: "미디어 항상 숨기기" -_mfm: - cheatSheet: "MFM 도움말" - intro: "MFM는 Misskey 기반 클라이언트의 다양한 곳에서 사용할 수 있는 전용 마크업 언어에요. 여기에서 MFM에서 사용할 수 있는 구문을 확인할 수 있어요." + force: "미디어 항상 가리기" +_cfm: + cheatSheet: "CFM 도움말" + intro: "CFM는 CherryPick 클라이언트의 다양한 곳에서 사용할 수 있는 전용 마크업 언어에요. 여기에서 CFM에서 사용할 수 있는 구문을 확인할 수 있어요." dummy: "CherryPick으로 연합우주의 세계가 펼쳐집니다" mention: "멘션" mentionDescription: "골뱅이표(@) 뒤에 사용자 이름을 넣어 특정 사용자를 지정할 수 있어요." @@ -2141,12 +2211,12 @@ _mfm: bg: "배경색" bgDescription: "지정한 값으로 배경색을 지정해요." plain: "평문" - plainDescription: "안에 있는 MFM 구문을 모두 무시하고 평문으로 표시해요." + plainDescription: "안에 있는 CFM 구문을 모두 무시하고 평문으로 표시해요." ruby: "루비" rubyDescription: "글자 위에 루비를 표시해요." _instanceTicker: none: "보이지 않음" - remote: "리모트 사용자에게만 보이기" + remote: "원격 서버의 사용자에게만 보이기" always: "항상 보이기" _serverDisconnectedBehavior: reload: "자동으로 새로고침" @@ -2266,6 +2336,7 @@ _soundSettings: driveFileTypeWarnDescription: "오디오 파일을 선택해 주세요." driveFileDurationWarn: "오디오가 너무 길어요." driveFileDurationWarnDescription: "긴 오디오를 사용하는 경우 CherryPick 사용자 경험에 영향을 끼칠 수 있어요. 그래도 사용하시겠어요?" + driveFileError: "오디오를 불러올 수 없어요. 설정을 변경해 주세요." _ago: future: "미래" justNow: "방금 전" @@ -2460,8 +2531,10 @@ _widgets: chooseList: "리스트 선택" clicker: "클리커" birthdayFollowings: "오늘 생일인 사용자" + search: "검색" + dice: "주사위" _cw: - hide: "숨기기" + hide: "가리기" show: "더 보기" chars: "{count} 문자" files: "{count} 파일" @@ -2524,6 +2597,9 @@ _profile: changeBanner: "배너 이미지 변경" verifiedLinkDescription: "내용에 자신의 프로필로 향하는 링크가 포함된 페이지의 URL을 삽입하면 소유자 인증 마크가 표시돼요." avatarDecorationMax: "최대 {max}개까지 장식을 달 수 있어요." + followedMessage: "팔로우 메시지" + followedMessageDescription: "상대방이 나를 팔로우했을 때 보이는 단문 메시지를 설정할 수 있어요." + followedMessageDescriptionForLockedAccount: "'팔로우를 수동으로 승인'으로 설정한 경우, 팔로우 요청을 수락했을 때 보여지게 돼요." _exportOrImport: allNotes: "모든 노트" favoritedNotes: "즐겨찾기한 노트" @@ -2543,7 +2619,7 @@ _charts: activeUsers: "활성 사용자 수" notesIncDec: "노트 수 증감" localNotesIncDec: "로컬 노트 수 증감" - remoteNotesIncDec: "리모트 노트 수 증감" + remoteNotesIncDec: "원격 서버 노트 수 증감" notesTotal: "노트 수 합계" filesIncDec: "파일 수 증감" filesTotal: "파일 수 합계" @@ -2616,6 +2692,7 @@ _pages: eyeCatchingImageSet: "아이캐치 이미지를 설정" eyeCatchingImageRemove: "아이캐치 이미지를 삭제" chooseBlock: "블록 추가" + enterSectionTitle: "섹션 타이틀을 입력하기" selectType: "종류 선택" contentBlocks: "콘텐츠" inputBlocks: "입력" @@ -2663,6 +2740,7 @@ _notification: renotedBySomeUsers: "{n}명이 리노트했어요" followedBySomeUsers: "{n}명에게 팔로우됨" flushNotification: "모든 알림 지우기" + exportOfXCompleted: "{x} 내보내기에 성공했어요." _types: all: "전부" note: "사용자의 새 게시물" @@ -2678,6 +2756,8 @@ _notification: groupInvited: "그룹에 초대됨" roleAssigned: "역할이 할당됨" achievementEarned: "도전 과제 획득" + exportCompleted: "내보내기를 완료함" + test: "알림 테스트" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" @@ -2729,6 +2809,7 @@ _webhookSettings: modifyWebhook: "Webhook 수정" name: "이름" secret: "시크릿" + trigger: "트리거" active: "활성화" _events: follow: "누군가를 팔로우 했을 때" @@ -2743,11 +2824,12 @@ _webhookSettings: abuseReportResolved: "받은 신고를 처리했을 때" userCreated: "사용자가 생성되었을 때" deleteConfirm: "이 Webhook을 삭제할까요?" + testRemarks: "스위치 오른쪽에 있는 버튼을 클릭해 더미 데이터를 사용한 테스트용 Webhook을 보낼 수 있어요." _abuseReport: _notificationRecipient: createRecipient: "신고 수신자 추가" modifyRecipient: "신고 수신자 편집" - recipientType: "알림 수신 유형" + recipientType: "알림 종류" _recipientType: mail: "이메일" webhook: "Webhook" @@ -2780,9 +2862,9 @@ _moderationLogTypes: deleteGlobalAnnouncement: "전역 공지사항 삭제" deleteUserAnnouncement: "사용자 공지사항 삭제" resetPassword: "비밀번호 재설정" - suspendRemoteInstance: "리모트 서버를 정지" - unsuspendRemoteInstance: "리모트 서버의 정지를 해제" - updateRemoteInstanceNote: "리모트 서버의 조정 기록 갱신" + suspendRemoteInstance: "원격 서버를 정지" + unsuspendRemoteInstance: "원격 서버의 정지를 해제" + updateRemoteInstanceNote: "원격 서버의 조정 기록 갱신" markSensitiveDriveFile: "파일을 열람 주의로 설정" unmarkSensitiveDriveFile: "파일의 열람 주의를 해제" resolveAbuseReport: "신고 처리" @@ -2801,6 +2883,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "신고 알림 수신자 생성" updateAbuseReportNotificationRecipient: "신고 알림 수신자 편집" deleteAbuseReportNotificationRecipient: "신고 알림 수신자 삭제" + deleteAccount: "계정을 삭제" + deletePage: "페이지를 삭제" + deleteFlash: "Play를 삭제" + deleteGalleryPost: "갤러리 포스트를 삭제" _fileViewer: title: "파일 상세" type: "파일 유형" @@ -2863,7 +2949,7 @@ _dataSaver: description: "URL 미리보기의 썸네일 이미지를 불러오지 않아요." _code: title: "코드 문법 강조" - description: "MFM 등에서 코드 문법 강조 기법을 사용할 때, 클릭하기 전까지는 불러오지 않아요. 코드 문법 강조 기능은 강조할 언어마다 해당 정의 파일을 불러와야 하지만, 이를 자동으로 불러오지 않게 되어 데이터 사용량을 줄일 수 있어요." + description: "CFM 등에서 코드 문법 강조 기법을 사용할 때, 클릭하기 전까지는 불러오지 않아요. 코드 문법 강조 기능은 강조할 언어마다 해당 정의 파일을 불러와야 하지만, 이를 자동으로 불러오지 않게 되어 데이터 사용량을 줄일 수 있어요." _hemisphere: N: "북반구" S: "남반구" @@ -2911,6 +2997,7 @@ _reversi: allowIrregularRules: "규칙 변경 허용(완전 자유)" disallowIrregularRules: "규칙 변경 없음" showBoardLabels: "판에 행·열 번호 표시" + showReaction: "상대의 리액션 표시" useAvatarAsStone: "돌을 아이콘으로 표시" _offlineScreen: title: "오프라인 - 서버에 연결할 수 없음" @@ -2933,6 +3020,25 @@ _mediaControls: pip: "화면 속 화면" playbackRate: "재생 속도" loop: "반복 재생" +_contextMenu: + title: "컨텍스트 메뉴" + app: "애플리케이션" + appWithShift: "Shift 키로 애플리케이션" + native: "브라우저의 UI" +_embedCodeGen: + title: "임베디드 코드를 커스터마이즈" + header: "해더를 표시" + autoload: "자동으로 다음 코드를 실행 (비권장)" + maxHeight: "최대 높이" + maxHeightDescription: "최대 값을 무시하려면 0을 입력해 주세요. 위젯이 상하로 길어지는 것을 방지하려면, 임의의 값을 입력해 주세요." + maxHeightWarn: "높이의 최대 값을 설정하지 않았아요(0). 의도적으로 설정한 것이 아니라면 0이 아닌 임의의 값으로 설정해 주세요." + previewIsNotActual: "미리보기로 표시할 수 있는 크기보다 커요. 실제로 넣은 코드의 표시와 다를 수 있어요." + rounded: "외곽선을 둥글게 하기" + border: "외곽선에 테두리를 씌우기" + applyToPreview: "미리보기에 반영" + generateCode: "임베디드 코드를 만들기" + codeGenerated: "코드를 만들었어요!" + codeGeneratedDescription: "만들어진 코드를 웹 사이트에 붙여서 사용해 주세요." _abuse: _resolver: 1hour: "1시간" @@ -2957,3 +3063,24 @@ _imageCompressionMode: noResizeCompress: "해상도를 축소하지 않고 압축" resizeCompressLossy: "해상도 축소 및 손실 압축" noResizeCompressLossy: "해상도를 축소하지 않고 손실 압축" +_externalNavigationWarning: + title: "외부 사이트로 이동할까요?" + description: "{host}을(를) 떠나 외부 사이트로 이동하려고 해요" + trustThisDomain: "이 장치에서 앞으로 이 도메인을 신뢰할게요" +_altWarning: + noAltWarning: "파일에 캡션이 설정되어 있지 않아요." + noAltWarningDescription: "이 설정은 [설정 - 모양]에서 변경할 수 있어요." +_dice: + rollDice: "주사위 던지기" + diceCount: "주사위 개수" + diceFaces: "주사위 면의 수" +_scheduledNoteDelete: + expiration: "삭제 기한" + at: "일시 지정" + after: "기간 지정" + deadlineDate: "기한" + deadlineTime: "시간" + duration: "기간" +_getQRCode: + title: "QR 코드 스캔하기" + description: "아래 QR 코드를 스캔하거나 공유할 수 있어요." diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 818046ddcd..40e59ca37d 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -502,15 +502,14 @@ groupInvited: "Zaproszony(-a) do grupy" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Natywny" -disableDrawer: "Nie używaj menu w stylu szuflady" youHaveNoGroups: "Nie masz żadnych grup" joinOrCreateGroup: "Uzyskaj zaproszenie do dołączenia do grupy lub utwórz własną grupę." showNoteActionsOnlyHover: "Pokazuj akcje notatek tylko po najechaniu myszką" showReactionsCount: "Wyświetl liczbę reakcji na notatkę" noHistory: "Brak historii" signinHistory: "Historia logowania" -enableAdvancedMfm: "Włącz zaawansowane MFM" -enableAnimatedMfm: "Włącz animowane MFM" +enableAdvancedMfm: "Włącz zaawansowane CFM" +enableAnimatedMfm: "Włącz animowane CFM" doing: "Przetwarzanie..." category: "Kategoria" tags: "Tagi" @@ -1133,9 +1132,9 @@ _aboutMisskey: donate: "Przekaż darowiznę na Misskey" morePatrons: "Naprawdę doceniam wsparcie ze strony wielu niewymienionych tu osób. Dziękuję! 🥰" patrons: "Wspierający" -_mfm: - cheatSheet: "Ściąga MFM" - intro: "MFM to język składniowy wyjątkowy dla Misskey, który może być użyty w wielu miejscach. Tu znajdziesz listę wszystkich możliwych elementów składni MFM." +_cfm: + cheatSheet: "Ściąga CFM" + intro: "CFM to język składniowy wyjątkowy dla CherryPick, który może być użyty w wielu miejscach. Tu znajdziesz listę wszystkich możliwych elementów składni CFM." dummy: "CherryPick rozszerza świat Fediwersum" mention: "Wspomnij" mentionDescription: "Używając znaku @ i nazwy użytkownika, możesz określić danego użytkownika." @@ -1194,7 +1193,7 @@ _mfm: rotate: "Obróć" rotateDescription: "Obraca zawartość o określony kąt." plain: "Zwyczajny" - plainDescription: "Wyłącza efekty wszystkich MFM zawartych w tym efekcie MFM." + plainDescription: "Wyłącza efekty wszystkich CFM zawartych w tym efekcie CFM." _instanceTicker: none: "Nigdy nie pokazuj" remote: "Pokaż dla zdalnych użytkowników" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 8cca054efc..743ed96d1d 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -9,7 +9,7 @@ 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…" @@ -82,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" @@ -272,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" @@ -296,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" @@ -312,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" @@ -509,13 +509,12 @@ 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" -enableAnimatedMfm: "Habilitar MFM animado" +enableAdvancedMfm: "Habilitar CFM avançado" +enableAnimatedMfm: "Habilitar CFM animado" doing: "Processando..." category: "Categoria" tags: "Etiquetas" @@ -700,7 +699,7 @@ 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." @@ -843,7 +842,7 @@ switchAccount: "Trocar conta" enabled: "Ativado" disabled: "Desativado" quickAction: "Ações rápidas" -user: "Usuários" +user: "Usuário" administration: "Administrar" accounts: "Contas" switch: "Trocar" @@ -1228,8 +1227,8 @@ 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" +addMfmFunction: "Adicionar CFM" +enableQuickAddMfmFunction: "Exibir seleção avançada de CFM" bubbleGame: "Bubble Game" sfx: "Efeitos Sonoros" soundWillBePlayed: "Sons serão reproduzidos" @@ -1263,6 +1262,7 @@ 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" @@ -2317,6 +2317,7 @@ _pages: 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" @@ -2369,7 +2370,7 @@ _notification: mention: "Menção" reply: "Respostas" renote: "Repostar" - quote: "Citar" + quote: "Citações" reaction: "Reações" pollEnded: "Enquetes terminando" receiveFollowRequest: "Recebeu pedidos de seguidor" @@ -2500,6 +2501,10 @@ _moderationLogTypes: 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" @@ -2561,7 +2566,7 @@ _dataSaver: 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." + description: "Se as notações de formatação de código forem utilizadas em CFM, 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" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 566914a43b..4f62211194 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 8dbf4654da..b49e81f24e 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -2,7 +2,7 @@ _lang_: "Русский" headlineMisskey: "Сеть, сплетённая из заметок" introMisskey: "Добро пожаловать! CherryPick — это децентрализованный сервис микроблогов с открытым исходным кодом.\nПишите «заметки» — делитесь со всеми происходящим вокруг или рассказывайте о себе 📡\nСтавьте «реакции» — выражайте свои чувства и эмоции от заметок других 👍\nОткройте для себя новый мир 🚀" -poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом CherryPick, называемый инстансом CherryPick." +poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом CherryPick, называемый экземпляром CherryPick." 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: "Ключ безопасности" @@ -467,10 +485,12 @@ noteOf: "Что пишет {user}" inviteToGroup: "Пригласить в группу" quoteAttached: "Цитата" quoteQuestion: "Хотите добавить цитату?" +attachAsFileQuestion: "Текста в буфере обмена слишком много. Прикрепить как текстовый файл?" noMessagesYet: "Пока ни одного сообщения" newMessageExists: "Новое сообщение" onlyOneFileCanBeAttached: "К сообщению можно прикрепить только один файл" signinRequired: "Пожалуйста, войдите" +signinOrContinueOnRemote: "Чтобы продолжить, вам необходимо войти в аккаунт на своём сервере или зарегистрироваться / войти в аккаунт на этом." invitations: "Приглашения" invitationCode: "Код приглашения" checking: "Проверка" @@ -480,7 +500,7 @@ usernameInvalidFormat: "Можно использовать только лат tooShort: "Слишком короткий" tooLong: "Слишком длинный" weakPassword: "Слабый пароль" -normalPassword: "Годный пароль" +normalPassword: "Хороший пароль" strongPassword: "Надёжный пароль" passwordMatched: "Совпали" passwordNotMatched: "Не совпадают" @@ -493,14 +513,14 @@ groupInvited: "Приглашение в группу" aboutX: "Описание {x}" emojiStyle: "Стиль эмодзи" native: "Системные" -disableDrawer: "Не использовать выдвижные меню" youHaveNoGroups: "У вас нет ни одной группы" joinOrCreateGroup: "Получайте приглашения в группы или создавайте свои собственные" showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении" +showReactionsCount: "Видеть количество реакций на заметках" noHistory: "История пока пуста" signinHistory: "Журнал посещений" -enableAdvancedMfm: "Включить расширенный MFM" -enableAnimatedMfm: "Включить анимированную разметку MFM" +enableAdvancedMfm: "Включить расширенный CFM" +enableAnimatedMfm: "Включить анимированную разметку CFM" doing: "В процессе" category: "Категория" tags: "Метки" @@ -559,7 +579,7 @@ popout: "Развернуть" volume: "Громкость" masterVolume: "Основная регулировка громкости" notUseSound: "Выключить звук" -useSoundOnlyWhenActive: "Использовать звук, когда Misskey активен." +useSoundOnlyWhenActive: "Воспроизводить звук только когда CherryPick активен." details: "Подробнее" chooseEmoji: "Выберите эмодзи" unableToProcess: "Не удаётся завершить операцию" @@ -613,7 +633,7 @@ poll: "Опрос" useCw: "Скрывать содержимое под предупреждением" enablePlayer: "Включить проигрыватель" disablePlayer: "Выключить проигрыватель" -expandTweet: "Развернуть твит" +expandTweet: "Развернуть заметку" themeEditor: "Редактор темы оформления" description: "Описание" describeFile: "Добавить подпись" @@ -625,7 +645,7 @@ plugins: "Расширения" preferencesBackups: "Резервная копия" deck: "Пульт" undeck: "Покинуть пульт" -useBlurEffectForModal: "Размывка под формой поверх всего" +useBlurEffectForModal: "Размытие за формой ввода заметки" useFullReactionPicker: "Полнофункциональный выбор реакций" width: "Ширина" height: "Высота" @@ -656,7 +676,7 @@ smtpSecure: "Использовать SSL/TLS для SMTP-соединений" smtpSecureInfo: "Выключите при использовании STARTTLS." testEmail: "Проверка доставки электронной почты" wordMute: "Скрытие слов" -hardWordMute: "" +hardWordMute: "Строгое скрытие слов" regexpError: "Ошибка в регулярном выражении" regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:" instanceMute: "Глушение инстансов" @@ -738,6 +758,7 @@ lockedAccountInfo: "Даже если вы вручную подтверждае alwaysMarkSensitive: "Отмечать файлы как «содержимое не для всех» по умолчанию" loadRawImages: "Сразу показывать изображения в полном размере" disableShowingAnimatedImages: "Не проигрывать анимацию" +highlightSensitiveMedia: "Выделять содержимое не для всех" verificationEmailSent: "Вам отправлено письмо для подтверждения. Пройдите, пожалуйста, по ссылке из письма, чтобы завершить проверку." notSet: "Не настроено" emailVerified: "Адрес электронной почты подтверждён." @@ -755,7 +776,7 @@ makeExplorable: "Опубликовать профиль в «Обзоре»." makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»." showGapBetweenNotesInTimeline: "Показывать разделитель между заметками в ленте" duplicate: "Дубликат" -left: "Влево" +left: "Слева" center: "По центру" wide: "Толстый" narrow: "Тонкий" @@ -834,7 +855,7 @@ noMaintainerInformationWarning: "Не заполнены сведения об noBotProtectionWarning: "Ботозащита не настроена" configure: "Настроить" postToGallery: "Опубликовать в галерею" -postToHashtag: "Написать заметку с этим хэштегом" +postToHashtag: "Написать заметку с этим хештегом" gallery: "Галерея" recentPosts: "Недавние публикации" popularPosts: "Популярные публикации" @@ -851,13 +872,13 @@ emailNotConfiguredWarning: "Не указан адрес электронной ratio: "Соотношение" previewNoteText: "Предварительный просмотр" customCss: "Индивидуальный CSS" -customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки здесь чреваты тем, что сайт перестанет нормально работать у вас." +customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки здесь чреваты тем, что у вас перестанет нормально работать сайт." global: "Всеобщая" squareAvatars: "Квадратные аватарки" sent: "Отправить" received: "Получено" searchResult: "Результаты поиска" -hashtags: "Хэштег" +hashtags: "Хештеги" troubleshooting: "Разрешение проблем" useBlurEffect: "Размытие в интерфейсе" learnMore: "Подробнее" @@ -869,7 +890,7 @@ accountDeletionInProgress: "В настоящее время выполняет usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания (_). Имена пользователей не могут быть изменены позже." aiChanMode: "Режим Ай" devMode: "Режим разработчика" -keepCw: "Сохраняйте Предупреждения о содержимом" +keepCw: "Сохраняйте предупреждения о содержимом" pubSub: "Учётные записи Pub/Sub" lastCommunication: "Последнее сообщение" resolved: "Решено" @@ -890,6 +911,8 @@ makeReactionsPublicDescription: "Список сделанных вами реа classic: "Классика" muteThread: "Скрыть цепочку" unmuteThread: "Отменить сокрытие цепочки" +followingVisibility: "Видимость подписок" +followersVisibility: "Видимость подписчиков" continueThread: "Показать следующие ответы" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" incorrectPassword: "Пароль неверен." @@ -1001,6 +1024,7 @@ assign: "Назначить" unassign: "Отменить назначение" color: "Цвет" manageCustomEmojis: "Управлять пользовательскими эмодзи" +manageAvatarDecorations: "Управление украшениями аватара" youCannotCreateAnymore: "Вы достигли лимита создания." cannotPerformTemporary: "Временно недоступен" cannotPerformTemporaryDescription: "Это действие временно невозможно выполнить из-за превышения лимита выполнения." @@ -1017,7 +1041,8 @@ thisPostMayBeAnnoying: "Это сообщение может быть непри thisPostMayBeAnnoyingHome: "Этот пост может быть отправлен на главную" thisPostMayBeAnnoyingCancel: "Этот пост не может быть отменен." thisPostMayBeAnnoyingIgnore: "Этот пост может быть проигнорирован " -collapseRenotes: "Свернуть репосты" +collapseRenotes: "Сворачивать увиденные репосты" +collapseRenotesDescription: "Сворачивать посты с которыми вы взаимодействовали." internalServerError: "Внутренняя ошибка сервера" internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка." copyErrorInfo: "Скопировать код ошибки" @@ -1041,7 +1066,10 @@ resetPasswordConfirm: "Сбросить пароль?" sensitiveWords: "Чувствительные слова" sensitiveWordsDescription: "Установите общедоступный диапазон заметки, содержащей заданное слово, на домашний. Можно сделать несколько настроек, разделив их переносами строк." sensitiveWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." +prohibitedWords: "Запрещённые слова" +prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой." prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." +hiddenTags: "Скрытые хештеги" notesSearchNotAvailable: "Поиск заметок недоступен" license: "Лицензия" unfavoriteConfirm: "Удалить избранное?" @@ -1052,9 +1080,14 @@ retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?" retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" +showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой" +reactionsDisplaySize: "Размер реакций" +limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере." noteIdOrUrl: "ID или ссылка на заметку" video: "Видео" videos: "Видео" +audio: "Звук" +audioFiles: "Звуковые файлы" dataSaver: "Экономия трафика" accountMigration: "Перенос учётной записи" accountMoved: "Учётная запись перенесена" @@ -1066,12 +1099,13 @@ editMemo: "Изменить памятку" reactionsList: "Список реакций" renotesList: "Репосты" notificationDisplay: "Отображение уведомлений" -leftTop: "Влево вверх" -rightTop: "Вправо вверх" -leftBottom: "Влево вниз" -rightBottom: "Вправо вниз" -vertical: "Вертикальная" -horizontal: "Сбоку" +leftTop: "Слева вверху" +rightTop: "Справа сверху" +leftBottom: "Слева внизу" +rightBottom: "Справа внизу" +stackAxis: "Положение уведомлений" +vertical: "Вертикально" +horizontal: "Горизонтально" position: "Позиция" serverRules: "Правила сервера" pleaseConfirmBelowBeforeSignup: "Для регистрации на данном сервере, необходимо согласится с нижеследующими положениями." @@ -1083,57 +1117,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: "К CherryPick" 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: "Если вы используете CherryPick как есть (без изменений в исходном коде), введите https://github.com/kokonect-link/cherrypick" +privacyPolicy: "Политика Конфиденциальности" +privacyPolicyUrl: "Ссылка на Политику Конфиденциальности" +attach: "Прикрепить" +angle: "Угол" flip: "Переворот" +disableStreamingTimeline: "Отключить обновление ленты в режиме реального времени" +useGroupedNotifications: "Отображать уведомления сгруппировано" +doReaction: "Добавить реакцию" code: "Код" +remainingN: "Остаётся: {n}" +seasonalScreenEffect: "Эффект времени года на экране" +decorate: "Украсить" +addMfmFunction: "Добавить CFM" 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: "Посты в CherryPick называются 'Заметками.' Заметки отсортированы в хронологическом порядке в ленте и обновляются в режиме реального времени." + _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(Это объяснение применяется к CherryPick v13.12.0 и выше. Другое ActivityPub программное обеспечение, такое, как Mastodon, может работать по-другому." + startMigration: "Перенести" + movedAndCannotBeUndone: "Аккаунт был перемещён. Это действие необратимо." _achievements: earnedAt: "Разблокировано в" _types: @@ -1412,6 +1503,7 @@ _role: canPublicNote: "Может публиковать общедоступные заметки" canInvite: "Может создавать пригласительные коды" canManageCustomEmojis: "Управлять пользовательскими эмодзи" + canManageAvatarDecorations: "Управление украшениями аватара" driveCapacity: "Доступное пространство на «диске»" alwaysMarkNsfw: "Всегда отмечать файлы как «не для всех»" pinMax: "Доступное количество закреплённых заметок" @@ -1522,9 +1614,14 @@ _aboutMisskey: donate: "Пожертвование на Misskey" morePatrons: "Большое спасибо и многим другим, кто принял участие в этом проекте! 🥰" patrons: "Материальная поддержка" -_mfm: - cheatSheet: "Подсказка по разметке MFM" - intro: "MFM — язык оформления текста, который придуман специально для Misskey и готов для применения во многих местах. На этой странице собраны и кратко изложены способы его использовать." + projectMembers: "Участники проекта" +_displayOfSensitiveMedia: + respect: "Скрывать содержимое не для всех" + ignore: "Показывать содержимое не для всех" + force: "Скрывать всё содержимое" +_cfm: + cheatSheet: "Подсказка по разметке CFM" + intro: "CFM — язык оформления текста, который придуман специально для CherryPick и готов для применения во многих местах. На этой странице собраны и кратко изложены способы его использовать." dummy: "CherryPick расширяет границы Федиверса." mention: "Упоминание" mentionDescription: "При помощи знака «собака» перед именем можно упомянуть какого-нибудь пользователя." @@ -1616,7 +1713,7 @@ _wordMute: muteWordsDescription: "Пишите слова через пробел в одной строке, чтобы фильтровать их появление вместе; а если хотите фильтровать любое из них, пишите в отдельных строках." muteWordsDescription2: "Здесь можно использовать регулярные выражения — просто заключите их между двумя дробными чертами (/)." _instanceMute: - instanceMuteDescription: "Заметки и репосты с указанных здесь инстансов, а также ответы пользователям оттуда же не будут отображаться." + instanceMuteDescription: "Любые активности, затрагивающие инстансы из данного списка, будут скрыты." instanceMuteDescription2: "Пишите каждый инстанс на отдельной строке" title: "Скрывает заметки с заданных инстансов." heading: "Список скрытых инстансов" @@ -1665,7 +1762,7 @@ _theme: navActive: "Текст на боковой панели (активирован)" navIndicator: "Индикатор на боковой панели" link: "Ссылка" - hashtag: "Хэштег" + hashtag: "Хештег" mention: "Упоминание" mentionMe: "Упоминания вас" renote: "Репост" @@ -1697,6 +1794,10 @@ _sfx: notification: "Уведомления" chat: "Сообщения" chatBg: "Сообщения (фон)" + reaction: "При выборе реакции" +_soundSettings: + driveFile: "Использовать аудиофайл с Диска." + driveFileWarn: "Выбрать аудиофайл с Диска." _ago: future: "Из будущего" justNow: "Только что" @@ -1775,6 +1876,7 @@ _permissions: "write:gallery": "Редактирование галереи" "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" + "write:admin:reset-password": "Сбросить пароль пользователю" _auth: shareAccessTitle: "Разрешения для приложений" shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" @@ -1829,6 +1931,7 @@ _widgets: _userList: chooseList: "Выберите список" clicker: "Счётчик щелчков" + birthdayFollowings: "Пользователи, у которых сегодня день рождения" _cw: hide: "Спрятать" show: "Показать" @@ -1882,7 +1985,7 @@ _profile: name: "Имя" username: "Имя пользователя" description: "О себе" - youCanIncludeHashtags: "Можете использовать здесь хэштеги" + youCanIncludeHashtags: "Можете использовать здесь хештеги." metadata: "Дополнительные сведения" metadataEdit: "Редактировать дополнительные сведения" metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль." @@ -1890,6 +1993,8 @@ _profile: metadataContent: "Содержимое" changeAvatar: "Поменять аватар" changeBanner: "Поменять изображение в шапке" + verifiedLinkDescription: "Указывая здесь URL, содержащий ссылку на профиль, иконка владения ресурсом может быть отображена рядом с полем" + avatarDecorationMax: "Вы можете добавить до {max} украшений." _exportOrImport: allNotes: "Все заметки\n" favoritedNotes: "Избранное" @@ -2014,6 +2119,9 @@ _notification: unreadAntennaNote: "Антенна {name}" emptyPushNotificationMessage: "Обновлены push-уведомления" achievementEarned: "Получено достижение" + checkNotificationBehavior: "Проверить внешний вид уведомления" + sendTestNotification: "Отправить тестовое уведомление" + flushNotification: "Очистить уведомления" _types: all: "Все" follow: "Подписки" @@ -2066,19 +2174,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 8f40be67fe..f85f922e08 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -464,14 +464,13 @@ groupInvited: "Pozvať do skupiny" aboutX: "O {x}" emojiStyle: "Štýl emoji" native: "Natívne" -disableDrawer: "Nepoužívať šuflíkové menu" youHaveNoGroups: "Nemáte žiadne skupiny" joinOrCreateGroup: "Požiadajte o pozvanie do existujúcej skupiny alebo vytvorte novú." showNoteActionsOnlyHover: "Ovládacie prvky poznámky sa zobrazujú len po nabehnutí myši" noHistory: "Žiadna história" signinHistory: "História prihlásení" -enableAdvancedMfm: "Povolenie pokročilého MFM" -enableAnimatedMfm: "Povoliť animované MFM" +enableAdvancedMfm: "Povolenie pokročilého CFM" +enableAnimatedMfm: "Povoliť animované CFM" doing: "Pracujem..." category: "Kategórie" tags: "Značky" @@ -1031,9 +1030,9 @@ _aboutMisskey: donate: "Podporiť Misskey" morePatrons: "Takisto oceňujeme podporu mnoých ďalších, ktorí tu nie sú uvedení. Ďakujeme! 🥰" patrons: "Prispievatelia" -_mfm: +_cfm: cheatSheet: "MFM Cheatsheet" - intro: "MFM je Misskey exkluzívny značkovací jazyk, ktorý sa dá používať na viacerých miestach. Tu môžete vidieť zoznam všetkej dostupnej MFM syntaxe." + intro: "CFM je CherryPick exkluzívny značkovací jazyk, ktorý sa dá používať na viacerých miestach. Tu môžete vidieť zoznam všetkej dostupnej CFM syntaxe." dummy: "CherryPick rozširuje svet Fediverza" mention: "Zmienka" mentionDescription: "Používateľa spomeniete použítím zavináča a mena používateľa" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 3cda136e63..b91ca8d5b0 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -519,15 +519,14 @@ groupInvited: "คุณได้รับเชิญให้เข้าร aboutX: "เกี่ยวกับ {x}" emojiStyle: "สไตล์ของเอโมจิ" native: "ภาษาแม่" -disableDrawer: "ไม่แสดงเมนูในรูปแบบลิ้นชัก" youHaveNoGroups: "คุณยังไม่มีกลุ่ม" joinOrCreateGroup: "รับเชิญเข้าร่วมกลุ่มหรือสร้างกลุ่มของคุณเองเลยนะ" showNoteActionsOnlyHover: "แสดงการดำเนินการโน้ตเมื่อโฮเวอร์(วางเมาส์เหนือ)เท่านั้น" showReactionsCount: "แสดงจำนวนรีแอกชั่นในโน้ต" noHistory: "ไม่มีประวัติ" signinHistory: "ประวัติการเข้าสู่ระบบ" -enableAdvancedMfm: "เปิดใช้งาน MFM ขั้นสูง" -enableAnimatedMfm: "เปิดการใช้งาน MFM แบบเคลื่อนไหว" +enableAdvancedMfm: "เปิดใช้งาน CFM ขั้นสูง" +enableAnimatedMfm: "เปิดการใช้งาน CFM แบบเคลื่อนไหว" doing: "กำลังประมวลผล......" category: "หมวดหมู่" tags: "นามแฝง" @@ -1243,7 +1242,7 @@ overwriteContentConfirm: "แน่ใจหรือไม่ว่าต้อ seasonalScreenEffect: "เอฟเฟกต์หน้าจอตามฤดูกาล" decorate: "ตกแต่ง" addMfmFunction: "เพิ่มการตกแต่ง" -enableQuickAddMfmFunction: "แสดงตัวจิ้มเลือก MFM ขั้นสูง" +enableQuickAddMfmFunction: "แสดงตัวจิ้มเลือก CFM ขั้นสูง" bubbleGame: "เกมบับเบิ้ล" sfx: "เสียงเอฟเฟ็กต์" soundWillBePlayed: "จะมีการเล่นเอฟเฟกต์เสียง" @@ -1867,9 +1866,9 @@ _displayOfSensitiveMedia: respect: "ซ่อนสื่อที่มีเนื้อหาละเอียดอ่อน" ignore: "แสดงสื่อที่มีเนื้อหาละเอียดอ่อน" force: "ซ่อนสื่อทั้งหมด" -_mfm: - cheatSheet: "โค้ด MFM Cheat Sheet" - intro: "MFM เป็นภาษามาร์กอัปพิเศษเฉพาะของ Misskey ที่สามารถใช้ได้ในหลายที่ คุณยังสามารถดูรายการไวยากรณ์ MFM ที่มีอยู่ทั้งหมดได้ที่นี่นะ" +_cfm: + cheatSheet: "โค้ด CFM Cheat Sheet" + intro: "CFM เป็นภาษามาร์กอัปพิเศษเฉพาะของ CherryPick ที่สามารถใช้ได้ในหลายที่ คุณยังสามารถดูรายการไวยากรณ์ CFM ที่มีอยู่ทั้งหมดได้ที่นี่นะ" dummy: "CherryPick ขยายโลกของ Fediverse" mention: "กล่าวถึง" mentionDescription: "คุณสามารถระบุผู้ใช้โดยใช้ At-Symbol และชื่อผู้ใช้ได้นะ" @@ -1932,7 +1931,7 @@ _mfm: rotate: "หมุนหน้าจอ" rotateDescription: "เปลี่ยนเนื้อหาตามด้วยมุมที่ระบุไว้" plain: "เรียบง่าย" - plainDescription: "ปิดการใช้งานเอฟเฟกต์ของ MFM ทั้งหมดที่มีอยู่ในเอฟเฟกต์ MFM นี้" + plainDescription: "ปิดการใช้งานเอฟเฟกต์ของ CFM ทั้งหมดที่มีอยู่ในเอฟเฟกต์ CFM นี้" _instanceTicker: none: "ไม่ต้องแสดง" remote: "แสดงสำหรับผู้ใช้ระยะไกล" @@ -2649,7 +2648,7 @@ _dataSaver: description: "ธัมบ์เนลแสดงตัวอย่าง URL จะไม่โหลดโดยอัตโนมัติ" _code: title: "ไฮไลต์โค้ด" - description: "หากใช้สัญลักษณ์ไฮไลต์โค้ดใน MFM ฯลฯ สัญลักษณ์เหล่านั้นจะไม่โหลดจนกว่าจะแตะ การไฮไลต์ไวยากรณ์(syntax)จำเป็นต้องดาวน์โหลดไฟล์คำจำกัดความของไฮไลต์สำหรับแต่ละภาษา ดังนั้นการปิดใช้งานการโหลดไฟล์เหล่านี้โดยอัตโนมัติจึงคาดว่าจะช่วยลดปริมาณข้อมูลการสื่อสารได้" + description: "หากใช้สัญลักษณ์ไฮไลต์โค้ดใน CFM ฯลฯ สัญลักษณ์เหล่านั้นจะไม่โหลดจนกว่าจะแตะ การไฮไลต์ไวยากรณ์(syntax)จำเป็นต้องดาวน์โหลดไฟล์คำจำกัดความของไฮไลต์สำหรับแต่ละภาษา ดังนั้นการปิดใช้งานการโหลดไฟล์เหล่านี้โดยอัตโนมัติจึงคาดว่าจะช่วยลดปริมาณข้อมูลการสื่อสารได้" _hemisphere: N: "ซีกโลกเหนือ" S: "ซีกโลกใต้" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index e35e2b067d..bb518f8bf0 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -462,13 +462,12 @@ uiLanguage: "Мова інтерфейсу" groupInvited: "Запрошення до групи" aboutX: "Про {x}" native: "місцевий" -disableDrawer: "Не використовувати висувні меню" youHaveNoGroups: "Немає груп" joinOrCreateGroup: "Отримуйте запрошення до груп або створюйте свої власні групи." noHistory: "Історія порожня" signinHistory: "Історія входів" -enableAdvancedMfm: "Увімкнути розширений MFM" -enableAnimatedMfm: "Увімкнути анімований MFM" +enableAdvancedMfm: "Увімкнути розширений CFM" +enableAnimatedMfm: "Увімкнути анімований CFM" doing: "Виконується" category: "Категорія" tags: "Теги" @@ -1235,9 +1234,9 @@ _aboutMisskey: donate: "Пожертвувати Misskey" morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених тут. Дякуємо! 🥰" patrons: "Підтримали" -_mfm: - cheatSheet: " Довідка MFM" - intro: "MFM це ексклюзивна мова розмітки тексту в Misskey, яку можна використовувати в багатьох місцях. Тут ви можете переглянути приклади її синтаксису." +_cfm: + cheatSheet: " Довідка CFM" + intro: "CFM це ексклюзивна мова розмітки тексту в CherryPick, яку можна використовувати в багатьох місцях. Тут ви можете переглянути приклади її синтаксису." dummy: "CherryPick розширює світ Федіверсу" mention: "Згадка" mentionDescription: "За допомогою знака \"@\" перед ім'ям можна згадати конкретного користувача." @@ -1293,7 +1292,7 @@ _mfm: fontDescription: "Встановлює шрифт для контенту." rotate: "Обертати" plain: "Звичайний" - plainDescription: "Деактивує всі ефекти MFM, що містяться в цьому ефекті MFM." + plainDescription: "Деактивує всі ефекти CFM, що містяться в цьому ефекті CFM." _instanceTicker: none: "Не відображати" remote: "Відображати для віддалених користувачів" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index 36cea2278f..a27d41a8c8 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -471,11 +471,10 @@ 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" -enableAdvancedMfm: "MFMni faollashtirish" +enableAdvancedMfm: "CFMni faollashtirish" doing: "Bajarilmoqda..." category: "kategoriya" tags: "teg" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 1a1df6e0e8..6cf2a57134 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -496,14 +496,13 @@ groupInvited: "Bạn đã được mời tham gia nhóm" 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" youHaveNoGroups: "Không có nhóm nào" joinOrCreateGroup: "Tham gia hoặc tạo một nhóm mới." 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" -enableAdvancedMfm: "Xem bài MFM chất lượng cao." -enableAnimatedMfm: "Xem bài MFM có chuyển động" +enableAdvancedMfm: "Xem bài CFM chất lượng cao." +enableAnimatedMfm: "Xem bài CFM có chuyển động" doing: "Đang xử lý..." category: "Phân loại" tags: "Thẻ" @@ -1472,9 +1471,9 @@ _aboutMisskey: donate: "Ủng hộ Misskey" morePatrons: "Chúng tôi cũng trân trọng sự hỗ trợ của nhiều người đóng góp khác không được liệt kê ở đây. Cảm ơn! 🥰" patrons: "Người ủng hộ" -_mfm: - cheatSheet: "MFM Cheatsheet" - intro: "MFM là ngôn ngữ phát triển độc quyền của Misskey có thể được sử dụng ở nhiều nơi. Tại đây bạn có thể xem danh sách tất cả các cú pháp MFM có sẵn." +_cfm: + cheatSheet: "CFM Cheatsheet" + intro: "CFM là ngôn ngữ phát triển độc quyền của CherryPick có thể được sử dụng ở nhiều nơi. Tại đây bạn có thể xem danh sách tất cả các cú pháp CFM có sẵn." dummy: "CherryPick mở rộng thế giới Fediverse" mention: "Nhắc đến" mentionDescription: "Bạn có thể nhắc đến ai đó bằng cách sử dụng @tên người dùng." @@ -1537,7 +1536,7 @@ _mfm: rotate: "Xoay" rotateDescription: "Xoay nội dung theo một góc cụ thể." plain: "Đơn giản" - plainDescription: "Vô hiệu hóa mọi hiệu ứng MFM chứa trong hiệu ứng MFM này." + plainDescription: "Vô hiệu hóa mọi hiệu ứng CFM chứa trong hiệu ứng CFM này." _instanceTicker: none: "Không hiển thị" remote: "Hiện cho người dùng từ máy chủ khác" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index f5dfe41ac8..3ae7b9f126 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -334,6 +334,7 @@ renameFolder: "重命名文件夹" deleteFolder: "删除文件夹" folder: "文件夹" addFile: "添加文件" +showFile: "显示文件" emptyDrive: "网盘中无文件" emptyFolder: "此文件夹中无文件" unableToDelete: "无法删除" @@ -519,15 +520,17 @@ groupInvited: "您有新的群组邀请" aboutX: "关于 {x}" emojiStyle: "表情符号的样式" native: "原生" -disableDrawer: "不显示抽屉菜单" +menuStyle: "菜单样式" +style: "样式" +popup: "弹窗" youHaveNoGroups: "没有群组" joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。" showNoteActionsOnlyHover: "仅在悬停时显示帖子操作" showReactionsCount: "显示帖子的回应数" noHistory: "没有历史记录" signinHistory: "登录历史" -enableAdvancedMfm: "启用扩展 MFM" -enableAnimatedMfm: "启用 MFM 动画" +enableAdvancedMfm: "启用扩展 CFM" +enableAnimatedMfm: "启用 CFM 动画" doing: "正在进行" category: "类别" tags: "标签" @@ -604,6 +607,8 @@ ascendingOrder: "升序" descendingOrder: "降序" scratchpad: "AiScript 控制台" scratchpadDescription: "AiScript 控制台为 AiScript 提供了实验环境。您可以编写代码与 CherryPick 交互,运行并查看结果。" +uiInspector: "UI 检查器" +uiInspectorDescription: "查看所有内存中由 UI 组件生成出的实例。UI 组件由 UI:C 系列函数所生成。" output: "输出" script: "脚本" disablePagesScript: "禁用页面脚本" @@ -1243,7 +1248,7 @@ overwriteContentConfirm: "将覆盖现有内容。确定吗?" seasonalScreenEffect: "符合当前季节的画面效果" decorate: "装饰" addMfmFunction: "添加装饰" -enableQuickAddMfmFunction: "显示高级 MFM 选择器" +enableQuickAddMfmFunction: "显示高级 CFM 选择器" bubbleGame: "泡泡游戏" sfx: "音效" soundWillBePlayed: "声音将会播放" @@ -1277,6 +1282,15 @@ confirmWhenRevealingSensitiveMedia: "显示敏感内容前需要确认" sensitiveMediaRevealConfirm: "这是敏感内容。是否显示?" createdLists: "已创建的列表" createdAntennas: "已创建的天线" +fromX: "从 {x}" +genEmbedCode: "生成嵌入代码" +noteOfThisUser: "此用户的帖子" +clipNoteLimitExceeded: "无法再往此便签内添加更多帖子" +performance: "性能" +signinWithPasskey: "使用通行密钥登录" +unknownWebAuthnKey: "此通行密钥未注册。" +passkeyVerificationFailed: "验证通行密钥失败。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。" _delivery: status: "投递状态" stop: "停止投递" @@ -1411,6 +1425,7 @@ _serverSettings: fanoutTimelineDescription: "当启用时,可显著提高获取各种时间线时的性能,并减轻数据库的负荷。但是相对的 Redis 的内存使用量将会增加。如果服务器的内存不是很大,又或者运行不稳定的话可以把它关掉。" fanoutTimelineDbFallback: "回退到数据库" fanoutTimelineDbFallbackDescription: "当启用时,若时间线未被缓存,则将额外查询数据库。禁用该功能可通过不执行回退处理进一步减少服务器负载,但会限制可检索的时间线范围。" + reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。" inquiryUrl: "联络地址" inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" _accountMigration: @@ -1612,7 +1627,7 @@ _achievements: _postedAt0min0sec: title: "报时" description: "在 0 点发布一篇帖子" - flavor: "嘣 嘣 嘣 Biu——!" + flavor: "报时信号最后一响,零点整" _selfQuote: title: "自我引用" description: "引用了自己的帖子" @@ -1664,8 +1679,8 @@ _achievements: flavor: "今年也请对本服务器多多指教!" _cookieClicked: title: "点击饼干小游戏" - description: "点击了可疑的饼干" - flavor: "是不是软件有问题?" + description: "点击了饼干" + flavor: "用错软件了?" _brainDiver: title: "Brain Diver" description: "发布了包含 Brain Diver 链接的帖子" @@ -1682,7 +1697,7 @@ _achievements: _bubbleGameDoubleExplodingHead: title: "两个🤯" description: "你合成出了2个游戏里最大的Emoji" - flavor: "" + flavor: "大约能 装满 这些便当盒 🤯 🤯 (比划)" _role: new: "创建角色" edit: "编辑角色" @@ -1748,6 +1763,11 @@ _role: canSearchNotes: "是否可以搜索帖子" canUseTranslator: "使用翻译功能" avatarDecorationLimit: "可添加头像挂件的最大个数" + canImportAntennas: "允许导入天线" + canImportBlocking: "允许导入拉黑列表" + canImportFollowing: "允许导入关注列表" + canImportMuting: "允许导入屏蔽列表" + canImportUserLists: "允许导入用户列表" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -1867,9 +1887,9 @@ _displayOfSensitiveMedia: respect: "隐藏敏感媒体" ignore: "显示敏感媒体" force: "隐藏所有内容" -_mfm: - cheatSheet: "MFM代码速查表" - intro: "MFM是一种在Misskey中的各个位置使用的专用标记语言。在这里您可以看到MFM中可用的语法列表。" +_cfm: + cheatSheet: "CFM代码速查表" + intro: "CFM是一种在CherryPick中的各个位置使用的专用标记语言。在这里您可以看到CFM中可用的语法列表。" dummy: "通过CherryPick扩展联邦宇宙的世界" mention: "提及" mentionDescription: "可以使用 @+用户名 来指示特定用户" @@ -2451,6 +2471,7 @@ _notification: renotedBySomeUsers: "{n} 人转发了" followedBySomeUsers: "被 {n} 人关注" flushNotification: "重置通知历史" + exportOfXCompleted: "已完成 {x} 个导出" _types: all: "全部" note: "用户的新帖子" @@ -2466,6 +2487,8 @@ _notification: groupInvited: "加入群组邀请" roleAssigned: "授予的角色" achievementEarned: "取得的成就" + exportCompleted: "已完成导出" + test: "测试通知" app: "关联应用的通知" _actions: followBack: "回关" @@ -2532,6 +2555,7 @@ _webhookSettings: abuseReportResolved: "当举报被处理时" userCreated: "当用户被创建时" deleteConfirm: "要删除 webhook 吗?" + testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。" _abuseReport: _notificationRecipient: createRecipient: "新建举报通知" @@ -2655,7 +2679,7 @@ _dataSaver: description: "将不再加载 URL 预览缩略图。" _code: title: "代码高亮" - description: "如果使用了代码高亮标记,例如在 MFM 中,则在点击之前不会加载。 代码高亮要求加载每种高亮语言的定义文件,由于这些文件不再自动加载,因此有望减少数据传输量。" + description: "如果使用了代码高亮标记,例如在 CFM 中,则在点击之前不会加载。 代码高亮要求加载每种高亮语言的定义文件,由于这些文件不再自动加载,因此有望减少数据传输量。" _hemisphere: N: "北半球" S: "南半球" @@ -2719,7 +2743,7 @@ _urlPreviewSetting: userAgent: "User-Agent" userAgentDescription: "设定获取预览时使用的 User-Agent。留空时将使用默认的 User-Agent。" summaryProxy: "用来生成预览的代理的 endpoint。" - summaryProxyDescription: "不使用 Misskey 本体,而是通过 Summaly Proxy 生成预览。" + summaryProxyDescription: "不使用 CherryPick 本体,而是通过 Summaly Proxy 生成预览。" summaryProxyDescription2: "下面的参数将作为查询字符串发送至代理。代理侧如果不支持此设置,则忽略设定值。" _mediaControls: pip: "画中画" @@ -2730,3 +2754,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 a4e917d05e..198db97aea 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: "無法刪除" @@ -519,15 +522,18 @@ groupInvited: "您有新的群組邀請" aboutX: "關於{x}" emojiStyle: "表情符號的風格" native: "原生" -disableDrawer: "不顯示下拉式選單" +menuStyle: "選單風格" +style: "風格" +drawer: "側邊欄" +popup: "彈出式視窗" youHaveNoGroups: "找不到群組" joinOrCreateGroup: "請加入現有群組,或創建新群組。" showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" showReactionsCount: "顯示貼文的反應數目" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" -enableAdvancedMfm: "啟用進階 MFM" -enableAnimatedMfm: "啟用 MFM 動畫" +enableAdvancedMfm: "啟用進階 CFM" +enableAnimatedMfm: "啟用 CFM 動畫" doing: "正在進行" category: "類別" tags: "標籤" @@ -604,6 +610,8 @@ ascendingOrder: "昇冪" descendingOrder: "降冪" scratchpad: "暫存記憶體" scratchpadDescription: "AiScript 控制臺為 AiScript 的實驗環境。您可以在此編寫、執行和確認程式碼與 CherryPick 互動的結果。" +uiInspector: "UI 檢查" +uiInspectorDescription: "您可以看到記憶體中存在的 UI 元件實例的清單。 UI 元件由 Ui:C: 系列函數產生。" output: "輸出" script: "腳本" disablePagesScript: "停用頁面的 AiScript 腳本" @@ -1200,7 +1208,7 @@ edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" followingOrFollower: "追隨中或追隨者" -fileAttachedOnly: "顯示包含附件的貼文" +fileAttachedOnly: "只顯示包含附件的貼文" showRepliesToOthersInTimeline: "顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" showRepliesToOthersInTimelineAll: "在時間軸包含追隨中所有人的回覆" @@ -1242,8 +1250,8 @@ remainingN: "剩餘:{n}" overwriteContentConfirm: "確定要覆蓋目前的內容嗎?" seasonalScreenEffect: "隨季節變換畫面的呈現" decorate: "設置頭像裝飾" -addMfmFunction: "插入 MFM 功能語法" -enableQuickAddMfmFunction: "顯示高級 MFM 選擇器" +addMfmFunction: "插入 CFM 功能語法" +enableQuickAddMfmFunction: "顯示高級 CFM 選擇器" bubbleGame: "氣泡遊戲" sfx: "音效" soundWillBePlayed: "將播放音效" @@ -1277,6 +1285,18 @@ confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" sensitiveMediaRevealConfirm: "這是敏感媒體。確定要顯示嗎?" createdLists: "已建立的清單" createdAntennas: "已建立的天線" +fromX: "自 {x}" +genEmbedCode: "產生嵌入程式碼" +noteOfThisUser: "這個使用者的貼文列表" +clipNoteLimitExceeded: "沒辦法在這個摘錄中增加更多貼文了。" +performance: "性能" +modified: "已變更" +discard: "取消" +thereAreNChanges: "有 {n} 處的變更" +signinWithPasskey: "使用密碼金鑰登入" +unknownWebAuthnKey: "未註冊的金鑰。" +passkeyVerificationFailed: "驗證金鑰失敗。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。" _delivery: status: "傳送狀態" stop: "停止發送" @@ -1411,6 +1431,7 @@ _serverSettings: fanoutTimelineDescription: "如果啟用的話,檢索各個時間軸的性能會顯著提昇,資料庫的負荷也會減少。不過,Redis 的記憶體使用量會增加。如果伺服器的記憶體容量比較少或者運行不穩定,可以停用。" fanoutTimelineDbFallback: "資料庫的回退" fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。" + reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。" inquiryUrl: "聯絡表單網址" inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" _accountMigration: @@ -1748,6 +1769,11 @@ _role: canSearchNotes: "可否搜尋貼文" canUseTranslator: "使用翻譯功能" avatarDecorationLimit: "頭像裝飾的最大設置量" + canImportAntennas: "允許匯入天線" + canImportBlocking: "允許匯入封鎖名單" + canImportFollowing: "允許匯入跟隨名單" + canImportMuting: "允許匯入靜音名單" + canImportUserLists: "允許匯入清單" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -1867,9 +1893,9 @@ _displayOfSensitiveMedia: respect: "隱藏敏感檔案" ignore: "顯示敏感檔案" force: "隱藏所有檔案" -_mfm: - cheatSheet: "MFM代碼小抄" - intro: "MFM是Misskey專用的標記語言,可以在Misskey中的各個位置使用。 您可以這裏看到MFM可用語法列表。" +_cfm: + cheatSheet: "CFM代碼小抄" + intro: "CFM是CherryPick專用的標記語言,可以在CherryPick中的各個位置使用。 您可以這裏看到CFM可用語法列表。" dummy: "CherryPick拓展了Fediverse的世界" mention: "提及" mentionDescription: "透過 @+用戶名 來標示特定使用者。" @@ -2311,6 +2337,9 @@ _profile: changeBanner: "變更橫幅圖像" verifiedLinkDescription: "如果輸入包含您個人資料的網站 URL,欄位旁邊將出現驗證圖示。" avatarDecorationMax: "最多可以設置 {max} 個裝飾。" + followedMessage: "被追隨時的訊息" + followedMessageDescription: "可以設定被追隨時顯示給對方的訊息。" + followedMessageDescriptionForLockedAccount: "如果追隨是需要審核的話,在允許追隨請求之後顯示。" _exportOrImport: allNotes: "所有貼文" favoritedNotes: "「我的最愛」貼文" @@ -2451,6 +2480,7 @@ _notification: renotedBySomeUsers: "{n}人做了轉發" followedBySomeUsers: "被{n}人追隨了" flushNotification: "重置通知歷史紀錄" + exportOfXCompleted: "{x} 的匯出已完成。" _types: all: "全部 " note: "使用者的最新貼文" @@ -2466,6 +2496,8 @@ _notification: groupInvited: "加入社群邀請" roleAssigned: "已授予角色" achievementEarned: "獲得成就" + exportCompleted: "已完成匯出。" + test: "通知測試" app: "應用程式通知" _actions: followBack: "追隨回去" @@ -2532,6 +2564,7 @@ _webhookSettings: abuseReportResolved: "當處理了使用者的檢舉時" userCreated: "使用者被新增時" deleteConfirm: "請問是否要刪除 Webhook?" + testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。" _abuseReport: _notificationRecipient: createRecipient: "新增接收檢舉的通知對象" @@ -2655,7 +2688,7 @@ _dataSaver: description: "將不再自動載入網址預覽縮圖。" _code: title: "程式碼突出顯示" - description: "如果使用了 MFM 的程式碼突顯標記,則在點擊之前不會載入。程式碼突顯要求加載每種程式語言的突顯定義檔案,但由於這些檔案不再自動載入,因此有望減少資料流量。" + description: "如果使用了 CFM 的程式碼突顯標記,則在點擊之前不會載入。程式碼突顯要求加載每種程式語言的突顯定義檔案,但由於這些檔案不再自動載入,因此有望減少資料流量。" _hemisphere: N: "北半球" S: "南半球" @@ -2730,3 +2763,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/package.json b/package.json index 1e41b9a7fd..4e8c1c6422 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cherrypick", - "version": "4.11.1", - "basedMisskeyVersion": "2024.8.0", + "version": "4.12.0", + "basedMisskeyVersion": "2024.9.0", "codename": "nasubi", "repository": { "type": "git", @@ -9,7 +9,9 @@ }, "packageManager": "pnpm@9.6.0", "workspaces": [ + "packages/frontend-shared", "packages/frontend", + "packages/frontend-embed", "packages/backend", "packages/sw", "packages/cherrypick-js", @@ -35,9 +37,11 @@ "watch": "pnpm dev", "dev": "node scripts/dev.mjs", "lint": "pnpm -r lint", + "biome-lint": "pnpm -r biome-lint", "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", @@ -57,24 +61,25 @@ "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": { + "@biomejs/biome": "1.9.3", "@misskey-dev/eslint-plugin": "2.0.3", "@types/node": "20.14.12", "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.0", "cross-env": "7.0.3", - "cypress": "13.13.1", + "cypress": "13.14.2", "eslint": "9.8.0", - "globals": "15.8.0", + "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" diff --git a/packages/backend/assets/embed.js b/packages/backend/assets/embed.js new file mode 100644 index 0000000000..487effd183 --- /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/biome.json b/packages/backend/biome.json new file mode 100644 index 0000000000..ab73fd8682 --- /dev/null +++ b/packages/backend/biome.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noBannedTypes": "error", + "noExtraBooleanCast": "error", + "noMultipleSpacesInRegularExpressionLiterals": "error", + "noUselessCatch": "error", + "noUselessTypeConstraint": "error", + "noWith": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "warn", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "warn", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "off", + "noInvalidConstructorSuper": "error", + "noNewSymbol": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedVariables": "error", + "useArrayLiterals": "off", + "useIsNan": "error", + "useValidForDirection": "error", + "useYield": "error" + }, + "style": { + "noDefaultExport": "warn", + "noInferrableTypes": "warn", + "noNamespace": "error", + "noNonNullAssertion": "warn", + "noParameterAssign": "warn", + "noRestrictedGlobals": { + "level": "error", + "options": { "deniedGlobals": ["__dirname", "__filename"] } + }, + "noVar": "error", + "useAsConstAssertion": "error" + }, + "suspicious": { + "noAsyncPromiseExecutor": "off", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noControlCharactersInRegex": "warn", + "noDebugger": "error", + "noDoubleEquals": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "off", + "noExplicitAny": "warn", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noMisleadingCharacterClass": "error", + "noMisleadingInstantiator": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noUnsafeDeclarationMerging": "error", + "noUnsafeNegation": "error", + "useGetterReturn": "error", + "useValidTypeof": "error" + } + }, + "ignore": [ + "**/node_modules", + "built", + "@types/**/*", + "migration" + ] + }, + "overrides": [ + { + "include": ["*.ts", "*.tsx", "*.mts", "*.cts"], + "linter": { + "rules": { + "correctness": { + "noConstAssign": "off", + "noGlobalObjectCalls": "off", + "noInvalidConstructorSuper": "off", + "noInvalidNewBuiltin": "off", + "noNewSymbol": "off", + "noSetterReturn": "off", + "noUndeclaredVariables": "off", + "noUnreachable": "off", + "noUnreachableSuper": "off" + }, + "style": { + "noArguments": "error", + "noVar": "error", + "useConst": "error" + }, + "suspicious": { + "noDuplicateClassMembers": "off", + "noDuplicateObjectKeys": "off", + "noDuplicateParameters": "off", + "noFunctionAssign": "off", + "noImportAssign": "off", + "noRedeclare": "off", + "noUnsafeNegation": "off", + "useGetterReturn": "off" + } + } + } + } + ] +} diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 5a4aa4e15a..e486e60a0e 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -23,7 +23,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], // The directory where Jest should output its coverage files - coverageDirectory: "coverage", + coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection // coveragePathIgnorePatterns: [ @@ -31,7 +31,7 @@ module.exports = { // ], // Indicates which provider should be used to instrument code for coverage - coverageProvider: "v8", + coverageProvider: 'v8', // A list of reporter names that Jest uses when writing coverage reports // coverageReporters: [ @@ -129,7 +129,7 @@ module.exports = { // A list of paths to directories that Jest should use to search for files in roots: [ - "" + '', ], // Allows you to use a custom runner instead of Jest's default test runner @@ -148,7 +148,7 @@ module.exports = { // snapshotSerializers: [], // The test environment that will be used for testing - testEnvironment: "node", + testEnvironment: 'node', // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, @@ -158,8 +158,8 @@ module.exports = { // The glob patterns Jest uses to detect test files testMatch: [ - "/test/unit/**/*.ts", - "/src/**/*.test.ts", + '/test/unit/**/*.ts', + '/src/**/*.test.ts', ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped @@ -184,7 +184,7 @@ module.exports = { // A map from regular expressions to paths to transformers transform: { - "^.+\\.(t|j)sx?$": ["@swc/jest"], + '^.+\\.(t|j)sx?$': ['@swc/jest'], }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/packages/backend/jest.config.e2e.cjs b/packages/backend/jest.config.e2e.cjs index 4502da47df..09d615693a 100644 --- a/packages/backend/jest.config.e2e.cjs +++ b/packages/backend/jest.config.e2e.cjs @@ -3,13 +3,13 @@ * https://jestjs.io/docs/en/configuration.html */ -const base = require('./jest.config.cjs') +const base = require('./jest.config.cjs'); module.exports = { ...base, - globalSetup: "/built-test/entry.js", - setupFilesAfterEnv: ["/test/jest.setup.ts"], + globalSetup: '/built-test/entry.js', + setupFilesAfterEnv: ['/test/jest.setup.ts'], testMatch: [ - "/test/e2e/**/*.ts", + '/test/e2e/**/*.ts', ], }; diff --git a/packages/backend/jest.config.unit.cjs b/packages/backend/jest.config.unit.cjs index aa5992936b..3137c7ac15 100644 --- a/packages/backend/jest.config.unit.cjs +++ b/packages/backend/jest.config.unit.cjs @@ -3,12 +3,12 @@ * https://jestjs.io/docs/en/configuration.html */ -const base = require('./jest.config.cjs') +const base = require('./jest.config.cjs'); module.exports = { ...base, testMatch: [ - "/test/unit/**/*.ts", - "/src/**/*.test.ts", + '/test/unit/**/*.ts', + '/src/**/*.test.ts', ], }; diff --git a/packages/backend/migration/1711008460816-external-website-warn.js b/packages/backend/migration/1711008460816-external-website-warn.js new file mode 100644 index 0000000000..a1c9d001bf --- /dev/null +++ b/packages/backend/migration/1711008460816-external-website-warn.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ExternalWebsiteWarn1711008460816 { + name = 'ExternalWebsiteWarn1711008460816' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "trustedLinkUrlPatterns" character varying(3072) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "trustedLinkUrlPatterns"`); + } +} diff --git a/packages/backend/migration/1720161864577-AddDeleteAt.js b/packages/backend/migration/1720161864577-AddDeleteAt.js new file mode 100644 index 0000000000..9257fae14e --- /dev/null +++ b/packages/backend/migration/1720161864577-AddDeleteAt.js @@ -0,0 +1,11 @@ +export class AddDeleteAt1720161864577 { + name = 'AddDeleteAt1720161864577' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "deleteAt" TIMESTAMP WITH TIME ZONE`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "deleteAt"`) + } +} diff --git a/packages/backend/migration/1723944246767-followedMessage.js b/packages/backend/migration/1723944246767-followedMessage.js new file mode 100644 index 0000000000..fc9ad1cb85 --- /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/1723982389378-AddCustomSplash.js b/packages/backend/migration/1723982389378-AddCustomSplash.js new file mode 100644 index 0000000000..a69d208d70 --- /dev/null +++ b/packages/backend/migration/1723982389378-AddCustomSplash.js @@ -0,0 +1,11 @@ +export class AddCustomSplash1723982389378 { + name = 'AddCustomSplash1723982389378' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "customSplashText" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "customSplashText"`); + } +} diff --git a/packages/backend/migration/1726804538569-reactions-buffering.js b/packages/backend/migration/1726804538569-reactions-buffering.js new file mode 100644 index 0000000000..bc19e9cc8a --- /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/1727491883993-user-score.js b/packages/backend/migration/1727491883993-user-score.js new file mode 100644 index 0000000000..7292d5363c --- /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 0000000000..52c24df4f7 --- /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 dedc944ce4..3b5dea1e96 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,6 +22,9 @@ "typecheck": "tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", + "biome-lint": "pnpm typecheck && pnpm biome lint", + "format": "pnpm biome format", + "format:write": "pnpm biome format --write", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs", @@ -68,26 +71,26 @@ "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", "@google-cloud/logging": "^10.5.0", "@google-cloud/translate": "^7.2.1", "@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", @@ -101,44 +104,46 @@ "accepts": "1.3.8", "ajv": "8.17.1", "archiver": "7.0.1", + "argon2": "^0.40.1", "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", + "cfm-js": "0.24.0-cherrypick.8", "chalk": "5.3.0", "chalk-template": "1.1.0", "cherrypick-js": "workspace:*", - "cherrypick-mfm-js": "0.24.0-cherrypick.4", "chokidar": "3.6.0", "cli-highlight": "2.1.11", "color-convert": "2.0.1", "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", "microformats-parser": "2.0.2", "mime-types": "2.1.35", "misskey-reversi": "workspace:*", @@ -146,24 +151,24 @@ "nanoid": "5.0.7", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.14", + "nodemailer": "6.9.15", "nsfwjs": "2.4.2", "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", @@ -171,18 +176,18 @@ "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", "strip-ansi": "^7.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", @@ -190,8 +195,9 @@ "xev": "3.0.2" }, "devDependencies": { + "@biomejs/biome": "1.9.3", "@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", @@ -200,10 +206,10 @@ "@types/body-parser": "1.19.5", "@types/color-convert": "2.0.3", "@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", @@ -211,18 +217,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", @@ -230,17 +236,17 @@ "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.3", - "@types/ws": "8.5.11", + "@types/ws": "8.5.12", "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.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.30.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/scripts/dev.mjs b/packages/backend/scripts/dev.mjs index a3e0558abd..cf82ca34bf 100644 --- a/packages/backend/scripts/dev.mjs +++ b/packages/backend/scripts/dev.mjs @@ -13,7 +13,7 @@ async function execBuildAssets() { cwd: '../../', stdout: process.stdout, stderr: process.stderr, - }) + }); } function execStart() { @@ -47,7 +47,7 @@ async function killProc() { ], { stdio: [process.stdin, process.stdout, process.stderr, 'ipc'], - serialization: "json", + serialization: 'json', }) .on('message', async (message) => { if (message.type === 'exit') { @@ -59,5 +59,5 @@ async function killProc() { await execBuildAssets(); execStart(); } - }) + }); })(); diff --git a/packages/backend/scripts/generate_api_json.js b/packages/backend/scripts/generate_api_json.js index 798e243004..bd72495385 100644 --- a/packages/backend/scripts/generate_api_json.js +++ b/packages/backend/scripts/generate_api_json.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { writeFileSync, existsSync } from 'node:fs'; import { execa } from 'execa'; -import { writeFileSync, existsSync } from "node:fs"; async function main() { if (!process.argv.includes('--no-build')) { diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 416415e85b..8ed5e49ab4 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -9,11 +9,13 @@ import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; import { MeiliSearch } from 'meilisearch'; import { Logging } from '@google-cloud/logging'; +import { MiMeta } from '@/models/Meta.js'; import { DI } from './di-symbols.js'; import { Config, loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import { allSettled } from './misc/promise-tracker.js'; +import { GlobalEvents } from './core/GlobalEventService.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; const $config: Provider = { @@ -94,6 +96,71 @@ 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], +}; + const $redisForJobQueue: Provider = { provide: DI.redisForJobQueue, useFactory: (config: Config) => { @@ -109,8 +176,8 @@ const $redisForJobQueue: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $cloudLogging, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForJobQueue], - exports: [$config, $db, $meilisearch, $cloudLogging, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForJobQueue, RepositoryModule], + providers: [$config, $db, $meta, $meilisearch, $cloudLogging, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForJobQueue], + exports: [$config, $db, $meta, $meilisearch, $cloudLogging, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForJobQueue, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @@ -119,6 +186,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, @Inject(DI.redisForJobQueue) private redisForJobQueue: Redis.Redis, ) { } @@ -132,6 +200,7 @@ export class GlobalModule implements OnApplicationShutdown { this.redisForPub.disconnect(), this.redisForSub.disconnect(), this.redisForTimelines.disconnect(), + this.redisForReactions.disconnect(), this.redisForJobQueue.disconnect(), ]); } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c2d5ebe220..c3e0446457 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; @@ -141,7 +142,7 @@ export type Config = { proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; allowedPrivateNetworks: string[] | undefined; - maxFileSize: number | undefined; + maxFileSize: number; clusterLimit: number | undefined; id: string; outgoingAddress: string | undefined; @@ -177,8 +178,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; @@ -186,6 +189,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; @@ -213,10 +217,16 @@ const path = process.env.CHERRYPICK_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.CHERRYPICK_URL ?? ''); @@ -262,6 +272,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, @@ -269,7 +280,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, @@ -290,8 +301,10 @@ export function loadConfig(): Config { config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator : null, userAgent: `CherryPick/${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), diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index a238f4973a..e3a61861f4 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 7be5335885..fe2c63e7d6 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 b6b591d240..6e3125044c 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/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index d9b40c1734..3941a1c87d 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -111,19 +111,25 @@ 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 === 'group') { const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! }); - const groupUsers = (await this.userGroupJoiningsRepository.findBy({ - userGroupId: joining.userGroupId, - })).map(x => x.userId); - - if (!groupUsers.includes(note.userId)) return false; + if (antenna.userGroupJoiningId == null) return false; + const exists = await this.userGroupJoiningsRepository.exists({ + where: { + userGroupId: joining.userGroupId, + 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 0a8085d4d2..4093b2c452 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -128,9 +128,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { const userHostUrl = `https://${user.host}`; const showUserApiUrl = `${userHostUrl}/api/users/show`; - if (instance?.softwareName !== 'misskey' && instance?.softwareName !== 'cherrypick') { - return; - } + if (!['misskey', 'cherrypick', 'sharkey'].includes(instance?.softwareName)) return; const res = await this.httpRequestService.send(showUserApiUrl, { method: 'POST', diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 5109628e1e..a0351739f5 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'; @@ -51,6 +52,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'; @@ -200,6 +202,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 }; @@ -219,6 +222,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 }; @@ -354,6 +358,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv PushNotificationService, QueryService, ReactionService, + ReactionsBufferingService, RelayService, RoleService, S3Service, @@ -373,6 +378,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv VideoProcessingService, UserWebhookService, SystemWebhookService, + WebhookTestService, UtilityService, FileInfoService, SearchService, @@ -504,6 +510,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $PushNotificationService, $QueryService, $ReactionService, + $ReactionsBufferingService, $RelayService, $RoleService, $S3Service, @@ -523,6 +530,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $VideoProcessingService, $UserWebhookService, $SystemWebhookService, + $WebhookTestService, $UtilityService, $FileInfoService, $SearchService, @@ -655,6 +663,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv PushNotificationService, QueryService, ReactionService, + ReactionsBufferingService, RelayService, RoleService, S3Service, @@ -674,6 +683,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv VideoProcessingService, UserWebhookService, SystemWebhookService, + WebhookTestService, UtilityService, FileInfoService, SearchService, @@ -804,6 +814,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $PushNotificationService, $QueryService, $ReactionService, + $ReactionsBufferingService, $RelayService, $RoleService, $S3Service, @@ -823,6 +834,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $VideoProcessingService, $UserWebhookService, $SystemWebhookService, + $WebhookTestService, $UtilityService, $FileInfoService, $SearchService, diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts index 6c5b0f6a36..7de67dade1 100644 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -5,7 +5,8 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; +//import bcrypt from 'bcryptjs'; import { IsNull, DataSource } from 'typeorm'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { MiUser } from '@/models/User.js'; @@ -32,8 +33,8 @@ export class CreateSystemUserService { const password = randomUUID(); // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); + //const salt = await bcrypt.genSalt(8); + const hash = await argon2.hash(password); // Generate secret const secret = generateNativeUserToken(); diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 41aebde7cb..e453b99aad 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -88,7 +88,7 @@ export class CustomEmojiService implements OnApplicationShutdown { this.localEmojisCache.refresh(); //this.prefetchEmojis([{name: data.name, host: null}]); - this.cache.set(`${data.name} ${data.host}`, emoji); + this.emojisCache.set(`${data.name} ${data.host}`, emoji); this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(emoji.id), diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 21ae798f9f..93f4a38246 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 27a0b36028..d55b3e334a 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, @@ -150,9 +151,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_-]+)$/) ?? ['']); @@ -171,13 +170,13 @@ export class DriveService { ext = ''; } - const useObjectStorageRemote = isRemote && meta.useObjectStorageRemote; - const objectStorageBaseUrl = useObjectStorageRemote ? meta.objectStorageRemoteBaseUrl : meta.objectStorageBaseUrl; - const objectStorageUseSSL = useObjectStorageRemote ? meta.objectStorageRemoteUseSSL : meta.objectStorageUseSSL; - const objectStorageEndpoint = useObjectStorageRemote ? meta.objectStorageRemoteEndpoint : meta.objectStorageEndpoint; - const objectStoragePort = useObjectStorageRemote ? meta.objectStorageRemotePort : meta.objectStoragePort; - const objectStorageBucket = useObjectStorageRemote ? meta.objectStorageRemoteBucket : meta.objectStorageBucket; - const objectStoragePrefix = useObjectStorageRemote ? meta.objectStorageRemotePrefix : meta.objectStoragePrefix; + const useObjectStorageRemote = isRemote && this.meta.useObjectStorageRemote; + const objectStorageBaseUrl = useObjectStorageRemote ? this.meta.objectStorageRemoteBaseUrl : this.meta.objectStorageBaseUrl; + const objectStorageUseSSL = useObjectStorageRemote ? this.meta.objectStorageRemoteUseSSL : this.meta.objectStorageUseSSL; + const objectStorageEndpoint = useObjectStorageRemote ? this.meta.objectStorageRemoteEndpoint : this.meta.objectStorageEndpoint; + const objectStoragePort = useObjectStorageRemote ? this.meta.objectStorageRemotePort : this.meta.objectStoragePort; + const objectStorageBucket = useObjectStorageRemote ? this.meta.objectStorageRemoteBucket : this.meta.objectStorageBucket; + const objectStoragePrefix = useObjectStorageRemote ? this.meta.objectStorageRemotePrefix : this.meta.objectStoragePrefix; const baseUrl = objectStorageBaseUrl ?? `${ objectStorageUseSSL ? 'https' : 'http' }://${ objectStorageEndpoint }${ objectStoragePort ? `:${objectStoragePort}` : '' }/${ objectStorageBucket }`; @@ -385,11 +384,9 @@ 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 useObjectStorageRemote = isRemote && meta.useObjectStorageRemote; - const objectStorageBucket = useObjectStorageRemote ? meta.objectStorageRemoteBucket : meta.objectStorageBucket; - const objectStorageSetPublicRead = useObjectStorageRemote ? meta.objectStorageRemoteSetPublicRead : meta.objectStorageSetPublicRead; + const useObjectStorageRemote = isRemote && this.meta.useObjectStorageRemote; + const objectStorageBucket = useObjectStorageRemote ? this.meta.objectStorageRemoteBucket : this.meta.objectStorageBucket; + const objectStorageSetPublicRead = useObjectStorageRemote ? this.meta.objectStorageRemoteSetPublicRead : this.meta.objectStorageSetPublicRead; const params = { Bucket: objectStorageBucket, @@ -407,7 +404,7 @@ export class DriveService { ); if (objectStorageSetPublicRead) params.ACL = 'public-read'; - await this.s3Service.upload(meta, params, isRemote) + await this.s3Service.upload(this.meta, params, isRemote) .then( result => { if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput @@ -473,32 +470,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.'); //} @@ -598,13 +594,13 @@ export class DriveService { file.maybeSensitive = info.sensitive; file.maybePorn = info.porn; file.isSensitive = user - ? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : + ? this.userEntityService.isLocalUser(user) && profile?.alwaysMarkNsfw ? true : sensitive ?? false : false; - if (user && this.utilityService.isMediaSilencedHost(instance.mediaSilencedHosts, user.host)) file.isSensitive = true; - if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; - if (info.sensitive && instance.setSensitiveFlagAutomatically) 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 && this.meta.setSensitiveFlagAutomatically) file.isSensitive = true; if (userRoleNSFW) file.isSensitive = true; if (url !== null) { @@ -666,7 +662,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); } } @@ -812,7 +808,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); } } @@ -834,16 +830,15 @@ export class DriveService { @bindThis public async deleteObjectStorageFile(key: string, isRemote: boolean) { - const meta = await this.metaService.fetch(); - const useObjectStorageRemote = isRemote && meta.useObjectStorageRemote; - const objectStorageBucket = useObjectStorageRemote ? meta.objectStorageRemoteBucket : meta.objectStorageBucket; + const useObjectStorageRemote = isRemote && this.meta.useObjectStorageRemote; + const objectStorageBucket = useObjectStorageRemote ? this.meta.objectStorageRemoteBucket : this.meta.objectStorageBucket; try { const param = { Bucket: objectStorageBucket, Key: key, } as DeleteObjectCommandInput; - await this.s3Service.delete(meta, param, isRemote); + await this.s3Service.delete(this.meta, param, isRemote); } 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 508eabf25a..4d1d694703 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -4,18 +4,17 @@ */ 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 { @@ -25,49 +24,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 = ` @@ -132,7 +123,7 @@ export class EmailService {
- +

${ subject }

@@ -146,7 +137,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}`); @@ -161,8 +163,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, @@ -180,11 +180,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, @@ -214,7 +214,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/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 439e1d867d..5cd4560209 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -224,6 +224,10 @@ export interface ReversiGameEventTypes { canceled: { userId: MiUser['id']; }; + reacted: { + userId: MiUser['id']; + reaction: string; + }; } //#endregion @@ -272,7 +276,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 eb192ee6da..793bbeecb1 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 ec630f804e..3d88d0aefe 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 6d1674d1a9..c489a5c4ca 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -14,7 +14,7 @@ import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import type { DefaultTreeAdapterMap } from 'parse5'; -import type * as mfm from 'cherrypick-mfm-js'; +import type * as mfm from 'cfm-js'; const treeAdapter = parse5.defaultTreeAdapter; type Node = DefaultTreeAdapterMap['node']; @@ -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/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index eb54928d18..2212b6f793 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -4,11 +4,10 @@ */ import { setImmediate } from 'node:timers/promises'; -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-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'; @@ -16,7 +15,7 @@ import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; import { MiEvent } from '@/models/Event.js'; import type { IEvent } from '@/models/Event.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'; @@ -25,11 +24,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'; @@ -53,7 +49,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'; @@ -62,6 +57,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'; @@ -151,16 +147,21 @@ type Option = { uri?: string | null; url?: string | null; app?: MiApp | null; + deleteAt?: Date | null; }; @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, @@ -215,7 +216,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, @@ -223,7 +223,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: { @@ -257,10 +259,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) { @@ -268,17 +268,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'; @@ -371,7 +371,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); @@ -446,6 +446,7 @@ export class NoteCreateService implements OnApplicationShutdown { renoteUserId: data.renote ? data.renote.userId : null, renoteUserHost: data.renote ? data.renote.userHost : null, userHost: user.host, + deleteAt: data.deleteAt, }); if (data.uri != null) insert.uri = data.uri; @@ -531,18 +532,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 (note.visibility !== 'specified' && (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); } }); @@ -748,6 +747,16 @@ export class NoteCreateService implements OnApplicationShutdown { }); } + if (data.deleteAt) { + const delay = data.deleteAt.getTime() - Date.now(); + this.queueService.scheduledNoteDeleteQueue.add(note.id, { + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + // Register to search database this.index(note); } @@ -878,15 +887,14 @@ export class NoteCreateService implements OnApplicationShutdown { @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: { @@ -896,9 +904,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 { @@ -936,9 +944,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); } } @@ -955,25 +963,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); @@ -982,9 +990,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) { @@ -1043,9 +1051,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 ( @@ -1061,12 +1069,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 81a2d708ca..7596e965c6 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, @@ -104,17 +103,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 index 42ce3ab196..8c5c76e82b 100644 --- a/packages/backend/src/core/NoteUpdateService.ts +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -7,7 +7,7 @@ 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 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; import type { NotesRepository, UsersRepository } from '@/models/_.js'; diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts index 71d663bf90..c3ff2a68d3 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 239124b484..eace4b3cfd 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'; @@ -57,13 +56,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 @@ -76,14 +76,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/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 98a25b0ff5..e4395792e1 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -17,6 +17,7 @@ import { RelationshipJobData, UserWebhookDeliverJobData, SystemWebhookDeliverJobData, + ScheduledNoteDeleteJobData, } from '../queue/types.js'; import type { Provider } from '@nestjs/common'; @@ -29,6 +30,7 @@ export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; export type UserWebhookDeliverQueue = Bull.Queue; export type SystemWebhookDeliverQueue = Bull.Queue; +export type ScheduledNoteDeleteQueue = Bull.Queue; const $system: Provider = { provide: 'queue:system', @@ -84,6 +86,12 @@ const $systemWebhookDeliver: Provider = { inject: [DI.config, DI.redisForJobQueue], }; +const $scheduledNoteDelete: Provider = { + provide: 'queue:scheduledNoteDelete', + useFactory: (config: Config, redisForJobQueue: Redis.Redis) => new Bull.Queue(QUEUE.SCHEDULED_NOTE_DELETE, baseQueueOptions(config, QUEUE.SCHEDULED_NOTE_DELETE, redisForJobQueue)), + inject: [DI.config, DI.redisForJobQueue], +}; + @Module({ imports: [ ], @@ -97,6 +105,7 @@ const $systemWebhookDeliver: Provider = { $objectStorage, $userWebhookDeliver, $systemWebhookDeliver, + $scheduledNoteDelete, ], exports: [ $system, @@ -108,6 +117,7 @@ const $systemWebhookDeliver: Provider = { $objectStorage, $userWebhookDeliver, $systemWebhookDeliver, + $scheduledNoteDelete, ], }) export class QueueModule implements OnApplicationShutdown { @@ -121,6 +131,7 @@ export class QueueModule implements OnApplicationShutdown { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, ) {} public async dispose(): Promise { @@ -137,6 +148,7 @@ export class QueueModule implements OnApplicationShutdown { this.objectStorageQueue.close(), this.userWebhookDeliverQueue.close(), this.systemWebhookDeliverQueue.close(), + this.scheduledNoteDeleteQueue.close(), ]); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 5a6b4f0c68..497fb54349 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -33,6 +33,7 @@ import type { SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, + ScheduledNoteDeleteQueue, } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -52,6 +53,7 @@ export class QueueService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, ) { this.systemQueue.add('tickCharts', { }, { @@ -88,6 +90,12 @@ export class QueueService { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('bakeBufferedReactions', { + }, { + repeat: { pattern: '0 0 * * *' }, + removeOnComplete: true, + }); } @bindThis @@ -458,10 +466,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, @@ -474,7 +487,7 @@ export class QueueService { }; return this.userWebhookDeliverQueue.add(webhook.id, data, { - attempts: 4, + attempts: opts?.attempts ?? 4, backoff: { type: 'custom', }, @@ -485,10 +498,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, @@ -500,7 +518,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 371207c33a..9ca7e5cc06 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': '👍', @@ -65,14 +64,14 @@ type DecodedReaction = { host?: string | null; }; -const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; +export const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; 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 0000000000..b4207c5106 --- /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/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 8c8b2b38a6..3d3c5533c9 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -21,6 +21,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { isCustomEmojiRegexp, ReactionService } from '@/core/ReactionService.js'; import { Serialized } from '@/types.js'; import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; @@ -45,6 +47,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { private globalEventService: GlobalEventService, private reversiGameEntityService: ReversiGameEntityService, private idService: IdService, + private customEmojiService: CustomEmojiService, + private reactionService: ReactionService, ) { } @@ -622,6 +626,34 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async sendReaction(gameId: MiReversiGame['id'], user: MiUser, reaction: string) { + const game = await this.get(gameId); + if (game == null) throw new Error('game not found'); + if (!game.isStarted || game.isEnded) return; + if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) return; + + let _reaction = '❤️'; + + const custom = reaction.match(isCustomEmojiRegexp); + + if (custom) { + const name = custom[1]; + + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); + if (emoji && !emoji.isSensitive) { + _reaction = `:${name}:`; + } + } else { + _reaction = this.reactionService.normalize(reaction); + } + + this.globalEventService.publishReversiGameStream(game.id, 'reacted', { + userId: user.id, + reaction: _reaction, + }); + } + @bindThis public dispose(): void { } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 449f88121e..c8c8a64c4b 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'; @@ -45,6 +45,7 @@ export type RolePolicies = { canManageAvatarDecorations: boolean; canSearchNotes: boolean; canUseTranslator: boolean; + canUseAutoTranslate: boolean; canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; @@ -59,6 +60,11 @@ export type RolePolicies = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + canImportAntennas: boolean; + canImportBlocking: boolean; + canImportFollowing: boolean; + canImportMuting: boolean; + canImportUserLists: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -75,6 +81,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canManageAvatarDecorations: false, canSearchNotes: false, canUseTranslator: true, + canUseAutoTranslate: false, canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, @@ -89,6 +96,11 @@ export const DEFAULT_POLICIES: RolePolicies = { userEachUserListsLimit: 50, rateLimitFactor: 1, avatarDecorationLimit: 1, + canImportAntennas: true, + canImportBlocking: true, + canImportFollowing: true, + canImportMuting: true, + canImportUserLists: true, }; @Injectable() @@ -103,8 +115,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, @@ -121,7 +133,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, @@ -343,8 +354,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; @@ -378,6 +388,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), + canUseAutoTranslate: calc('canUseAutoTranslate', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), @@ -392,6 +403,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/SignupService.ts b/packages/backend/src/core/SignupService.ts index de45898328..5374ec09f6 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -5,10 +5,11 @@ import { generateKeyPair } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; 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 +21,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 +29,9 @@ export class SignupService { @Inject(DI.db) private db: DataSource, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -39,7 +42,6 @@ export class SignupService { private userService: UserService, private userEntityService: UserEntityService, private idService: IdService, - private metaService: MetaService, private instanceActorService: InstanceActorService, private usersChart: UsersChart, ) { @@ -68,8 +70,8 @@ export class SignupService { } // Generate hash of password - const salt = await bcrypt.genSalt(8); - hash = await bcrypt.hash(password, salt); + //const salt = await bcrypt.genSalt(8); + hash = await argon2.hash(password); } // Generate secret @@ -88,8 +90,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 bc6851f788..bb7c6b8c0e 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 6aab8fde70..77e7b60bea 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/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts index e96bfeea95..8a40a53688 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 94729250a6..86082ccdcd 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 ec9f4484a4..75ab0a207c 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 0000000000..c2764f30e8 --- /dev/null +++ b/packages/backend/src/core/WebhookTestService.ts @@ -0,0 +1,435 @@ +/* + * 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, + ...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/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 8b4fd29cab..94687e9197 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -18,7 +18,6 @@ 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'; @@ -26,7 +25,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import { MessagingService } from '@/core/MessagingService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, MessagingMessagesRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, MiMeta, MessagingMessagesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -50,6 +49,9 @@ export class ApInboxService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -69,7 +71,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, @@ -321,9 +322,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 relays = await this.relayService.getAcceptedRelays(); const fromRelay = !!actor.inbox && relays.map(r => r.inbox).includes(actor.inbox); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 51400a0951..c195416eb2 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -4,7 +4,7 @@ */ import { Injectable } from '@nestjs/common'; -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import { MfmService } from '@/core/MfmService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 234d426bd5..2a9947aea4 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -6,7 +6,7 @@ import { createPublicKey, randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; @@ -518,6 +518,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 7cf8359212..c7d19adfd5 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -205,18 +205,47 @@ export class ApRequestService { //#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき const contentType = res.headers.get('content-type'); - if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) { + if ( + res.ok && + (contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && + _followAlternate === true + ) { const html = await res.text(); - const window = new Window(); + 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; - 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); + 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 diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index bb3c40f093..ca35608d9b 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 4700672f5c..9cb50fd689 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', '_misskey_talk': 'misskey:_misskey_talk', 'isCat': 'misskey:isCat', // vcard diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 3691967270..e7ece87b01 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 a300c2ef22..a12108c8d1 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 { EmojisRepository, MessagingMessagesRepository, NotesRepository, PollsRepository } from '@/models/_.js'; +import type { PollsRepository, EmojisRepository, MiMeta, MessagingMessagesRepository, NotesRepository } 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'; @@ -49,6 +48,9 @@ export class ApNoteService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @@ -75,7 +77,6 @@ export class ApNoteService { private apImageService: ApImageService, private apQuestionService: ApQuestionService, private apEventService: ApEventService, - private metaService: MetaService, private messagingService: MessagingService, private appLockService: AppLockService, private pollService: PollService, @@ -194,7 +195,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'); } @@ -451,9 +452,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 5bc70b50bc..f4fbdbd649 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'; @@ -47,7 +46,6 @@ 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, ICollection, IObject, IOrderedCollection } from '../type.js'; @@ -63,7 +61,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; @@ -85,6 +82,9 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.db) private db: DataSource, @@ -115,7 +115,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'); @@ -325,8 +324,8 @@ export class ApPersonService implements OnModuleInit { 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}/); @@ -457,6 +456,7 @@ 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, @@ -494,10 +494,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); } }); @@ -583,8 +583,8 @@ export class ApPersonService implements OnModuleInit { return undefined; } return 'private'; - }) - ) + }), + ), ); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); @@ -726,6 +726,7 @@ 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, diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index f3c4418478..b0db490452 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -13,6 +13,7 @@ export interface IObject { name?: string | null; summary?: string; _misskey_summary?: string; + _misskey_followedMessage?: string | null; published?: string; updated?: string; cc?: ApObject; diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index c2329a2f73..c9b43cc66d 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/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 4956bc22ce..284537b986 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, diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index c4dc95ec2a..bdcf57f2db 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') @@ -117,6 +118,7 @@ export class MetaEntityService { imageUrl: ad.imageUrl, dayOfWeek: ad.dayOfWeek, })), + trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns, notesPerOneAd: instance.notesPerOneAd, enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, @@ -130,6 +132,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; @@ -140,7 +143,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 1119411880..53c67f278c 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, EventsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, EventsRepository } 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, @@ -62,6 +66,8 @@ export class NoteEntityService implements OnModuleInit { //private driveFileEntityService: DriveFileEntityService, //private customEmojiService: CustomEmojiService, //private reactionService: ReactionService, + //private reactionsBufferingService: ReactionsBufferingService, + //private idService: IdService, ) { } @@ -70,6 +76,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'); } @@ -301,6 +308,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> @@ -317,6 +325,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)) { @@ -329,7 +346,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(':', '')); await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis([note])); @@ -353,10 +370,10 @@ export class NoteEntityService implements OnModuleInit { disableRightClick: note.disableRightClick || 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, @@ -395,9 +412,14 @@ export class NoteEntityService implements OnModuleInit { poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, event: note.hasEvent ? this.populateEvent(note) : undefined, - - ...(meId && Object.keys(note.reactions).length > 0 ? { - myReaction: this.populateMyReaction(note, meId, options?._hint_), + deleteAt: note.deleteAt?.toISOString() ?? undefined, + + ...(meId && Object.keys(reactions).length > 0 ? { + myReaction: this.populateMyReaction({ + id: note.id, + reactions: reactions, + reactionAndUserPairCache: reactionAndUserPairCache, + }, meId, options?._hint_), } : {}), } : {}), }); @@ -420,6 +442,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) { @@ -430,23 +454,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); } @@ -481,6 +515,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 625d65f60b..3bcd358270 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -66,7 +66,6 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types options: { checkValidNotifier?: boolean; }, @@ -169,9 +168,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, @@ -239,7 +245,6 @@ 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 b3b0e894b6..3edbe6a6c9 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -548,7 +548,7 @@ export class UserEntityService implements OnModuleInit { name: r.name, iconUrl: r.iconUrl, displayOrder: r.displayOrder, - })) + })), ) : undefined, ...(isDetailed ? { @@ -607,6 +607,7 @@ export class UserEntityService implements OnModuleInit { ...(isDetailed && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, + followedMessage: profile!.followedMessage, isModerator: isModerator, isAdmin: isAdmin, injectFeaturedNote: profile!.injectFeaturedNote, @@ -676,6 +677,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 2c70344c94..0fbe50c176 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -3,12 +3,13 @@ * 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 { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; import type { OnApplicationShutdown } from '@nestjs/common'; 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 21777657d1..42f925e125 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 8e9ccc3a00..52fcd5c60c 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -6,12 +6,14 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), + meta: Symbol('meta'), meilisearch: Symbol('meilisearch'), cloudLogging: Symbol('cloudLogging'), redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), redisForSub: Symbol('redisForSub'), redisForTimelines: Symbol('redisForTimelines'), + redisForReactions: Symbol('redisForReactions'), redisForJobQueue: Symbol('redisForJobQueue'), //#region Repositories diff --git a/packages/backend/src/misc/collapsed-queue.ts b/packages/backend/src/misc/collapsed-queue.ts new file mode 100644 index 0000000000..5bc20a78ae --- /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/extract-custom-emojis-from-mfm.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts index 17b9dafd1e..28ecba8b3b 100644 --- a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import { unique } from '@/misc/prelude/array.js'; export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] { diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts index 288c4a4c9e..a0c7f7ad3b 100644 --- a/packages/backend/src/misc/extract-hashtags.ts +++ b/packages/backend/src/misc/extract-hashtags.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import { unique } from '@/misc/prelude/array.js'; export function extractHashtags(nodes: mfm.MfmNode[]): string[] { diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts index 3c3c60b898..72e0730068 100644 --- a/packages/backend/src/misc/extract-mentions.ts +++ b/packages/backend/src/misc/extract-mentions.ts @@ -5,7 +5,7 @@ // test is located in test/extract-mentions -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { // TODO: 重複を削除 diff --git a/packages/backend/src/misc/fastify-hook-handlers.ts b/packages/backend/src/misc/fastify-hook-handlers.ts index 3e1c099e00..fa3ef0a267 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 6c1b92ef16..fa1823b0bc 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -148,7 +148,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; @@ -202,6 +204,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 : @@ -236,6 +239,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/models/Meta.ts b/packages/backend/src/models/Meta.ts index 58ac29eb33..00773f0715 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -697,6 +697,11 @@ export class MiMeta { }) public perUserListTimelineCacheMax: number; + @Column('boolean', { + default: false, + }) + public enableReactionsBuffering: boolean; + @Column('integer', { default: 0, }) @@ -722,6 +727,14 @@ export class MiMeta { }) public urlPreviewRequireContentLength: boolean; + @Column('varchar', { + length: 3072, + array: true, + default: '{}', + comment: 'An array of URL strings or regex that can be used to omit warnings about redirects to external sites. Separate them with spaces to specify AND, and enclose them with slashes to specify regular expressions. Each item is regarded as an OR.', + }) + public trustedLinkUrlPatterns: string[]; + @Column('varchar', { length: 1024, nullable: true, @@ -734,6 +747,19 @@ export class MiMeta { }) 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[]; + @Column('boolean', { default: false, }) @@ -759,4 +785,11 @@ export class MiMeta { nullable: true, }) public skipCherryPickVersion: string | null; + + @Column('varchar', { + length: 1024, + array: true, + default: '{}', + }) + public customSplashText: string[]; } diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9709dc840c..41939cf02c 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -258,6 +258,11 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public deleteAt: Date | null; //#endregion constructor(data: Partial) { diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 68ece6b947..647cac7397 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -3,11 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { userExportableEntities } from '@/types.js'; import { MiUser } from './User.js'; import { MiNote } from './Note.js'; import { MiUserGroupInvitation } from './UserGroupInvitation.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; +import { MiDriveFile } from './DriveFile.js'; export type MiNotification = { type: 'note'; @@ -68,6 +70,7 @@ export type MiNotification = { id: string; createdAt: string; notifierId: MiUser['id']; + message: string | null; } | { type: 'groupInvited'; id: string; @@ -84,6 +87,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; @@ -92,7 +101,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 a7f270b44f..2da7f3cf0c 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -157,6 +157,11 @@ export class MiUser { }) public tags: string[]; + @Column('integer', { + default: 0, + }) + public score: number; + @Column('boolean', { default: false, comment: 'Whether the User is suspended.', @@ -291,5 +296,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 dddd434c03..3668fd218d 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 db24c03b3d..b4cab4edc8 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/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 2ccb15ed1d..5f0bfd0488 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -190,6 +190,14 @@ export const packedMetaLiteSchema = { }, }, }, + trustedLinkUrlPatterns: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, notesPerOneAd: { type: 'number', optional: false, nullable: false, @@ -257,6 +265,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 980c868d0e..b00990d9e3 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -282,10 +282,14 @@ export const packedNoteSchema = { type: 'number', optional: true, nullable: false, }, - myReaction: { type: 'string', optional: true, nullable: true, }, + deleteAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 052eb84bf7..ef7a8c76f5 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 98f31a0e14..84d8e2a79c 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -216,6 +216,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canUseAutoTranslate: { + type: 'boolean', + optional: false, nullable: false, + }, canHideAds: { type: 'boolean', optional: false, nullable: false, @@ -272,6 +276,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, + }, canEditNote: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index b8efacd1a8..2f61a6a354 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -378,6 +378,10 @@ export const packedUserDetailedNotMeOnlySchema = { ref: 'RoleLite', }, }, + followedMessage: { + type: 'string', + nullable: true, optional: true, + }, memo: { type: 'string', nullable: true, optional: false, @@ -445,6 +449,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 cea0f46e46..8e604909ae 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'; @@ -40,6 +41,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; +import { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js'; @Module({ imports: [ @@ -52,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + BakeBufferedReactionsProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, @@ -81,6 +84,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor InboxProcessorService, AggregateRetentionProcessorService, QueueProcessorService, + ScheduledNoteDeleteProcessorService, ], exports: [ QueueProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 2fc82e2d16..bc082dec5f 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -41,8 +41,10 @@ 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 { ScheduledNoteDeleteProcessorService } from './processors/ScheduledNoteDeleteProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QUEUE, baseQueueOptions } from './const.js'; @@ -84,6 +86,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; + private scheduledNoteDeleteQueueWorker: Bull.Worker; constructor( @Inject(DI.config) @@ -124,7 +127,9 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private cleanProcessorService: CleanProcessorService, + private scheduledNoteDeleteProcessorService: ScheduledNoteDeleteProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -153,6 +158,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`); } @@ -508,6 +514,21 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } //#endregion + + //#region scheduled note delete + { + this.scheduledNoteDeleteQueueWorker = new Bull.Worker(QUEUE.SCHEDULED_NOTE_DELETE, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: ScheduledNoteDelete' }, () => this.scheduledNoteDeleteProcessorService.process(job)); + } else { + return this.scheduledNoteDeleteProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.SCHEDULED_NOTE_DELETE, this.redisForJobQueue), + autorun: false, + }); + } + //#endregion } @bindThis @@ -522,6 +543,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), + this.scheduledNoteDeleteQueueWorker.run(), ]); } @@ -537,6 +559,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), + this.scheduledNoteDeleteQueueWorker.close(), ]); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 488c76e024..2dc0a08e1c 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -17,6 +17,7 @@ export const QUEUE = { OBJECT_STORAGE: 'objectStorage', USER_WEBHOOK_DELIVER: 'userWebhookDeliver', SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver', + SCHEDULED_NOTE_DELETE: 'scheduledNoteDelete', }; export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE], redisConnection: Redis.Redis): Bull.QueueOptions { diff --git a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts new file mode 100644 index 0000000000..a477d8aa46 --- /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 { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; + +@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 4076e9da90..9590a4fe71 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, @@ -52,9 +53,7 @@ export class DeliverProcessorService { 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 88c4ea29c0..b3111865ad 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 6ec3c18786..ecc439db69 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 f463c36204..38b9f5b455 100644 --- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -17,6 +17,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'; @@ -41,6 +42,7 @@ export class ExportClipsProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-clips'); } @@ -77,6 +79,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 e4eb4791bd..e237cd4975 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 7bb626dd31..b81feece01 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 1cc80e66d7..903f962515 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 243b74f2c2..f9867ade29 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 7a10ea3a50..743d7a3151 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'; @@ -115,6 +116,7 @@ export class ExportNotesProcessorService { private queueLoggerService: QueueLoggerService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-notes'); } @@ -153,6 +155,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 ee87cff5d3..c483d79854 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 171809d25c..9e1b8fee70 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 fa7009f8f5..09d51bec72 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/queue/processors/ScheduledNoteDeleteProcessorService.ts b/packages/backend/src/queue/processors/ScheduledNoteDeleteProcessorService.ts new file mode 100644 index 0000000000..c522a75de6 --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduledNoteDeleteProcessorService.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { NotesRepository, UsersRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { ScheduledNoteDeleteJobData } from '../types.js'; + +@Injectable() +export class ScheduledNoteDeleteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private noteDeleteService: NoteDeleteService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('scheduled-note-delete'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + const note = await this.notesRepository.findOneBy({ id: job.data.noteId }); + + if (note == null) { + return; + } + + const user = await this.usersRepository.findOneBy({ id: note.userId }); + + if (user == null) { + return; + } + + await this.noteDeleteService.delete(user, note); + this.logger.info(`Delete note ${note.id}`); + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 8c0a87bfe5..0a5da0d3bc 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -133,3 +133,7 @@ export type UserWebhookDeliverJobData = { export type ThinUser = { id: MiUser['id']; }; + +export type ScheduledNoteDeleteJobData = { + noteId: MiNote['id']; +}; diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 77a637d895..41b6d2e83d 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 2c3ed85925..5980609f02 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/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 41b960fc0f..43b597bb06 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -48,6 +48,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: [ @@ -73,6 +74,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 ea6bdc51e4..d834f3ac8e 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 b5d9968972..62eb54c391 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'; @@ -41,13 +40,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, @@ -65,15 +66,6 @@ export class ApiCallService implements OnApplicationShutdown { let statusCode = err.httpStatusCode; if (err.httpStatusCode === 401) { reply.header('WWW-Authenticate', 'Bearer realm="CherryPick"'); - } else if (err.kind === 'client') { - reply.header('WWW-Authenticate', `Bearer realm="CherryPick", 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="CherryPick", 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(); @@ -84,6 +76,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="CherryPick", 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="CherryPick", error="insufficient_scope", error_description="${err.message}"`); + } + statusCode = statusCode ?? 403; } else if (!statusCode) { statusCode = 500; } @@ -200,9 +201,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; @@ -257,9 +267,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 4a5935f930..709a044601 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/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index f4b5da2258..dcf2ce59de 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -100,6 +100,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'; @@ -268,6 +269,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'; @@ -514,6 +516,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 }; @@ -682,6 +685,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 }; @@ -933,6 +937,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, @@ -1101,6 +1106,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, @@ -1345,6 +1351,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, @@ -1512,6 +1519,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, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index edac9b3beb..1554510697 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; @@ -123,7 +124,7 @@ export class SigninApiService { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); // Compare password - const same = await bcrypt.compare(password, profile.password!); + const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!); const fail = async (status?: number, failure?: { id: string }) => { // Append signin history @@ -140,6 +141,12 @@ export class SigninApiService { if (!profile.twoFactorEnabled) { if (same) { + if (profile.password!.startsWith('$2')) { + const newHash = await argon2.hash(password); + this.userProfilesRepository.update(user.id, { + password: newHash, + }); + } return this.signinService.signin(request, reply, user); } else { return await fail(403, { @@ -156,6 +163,12 @@ export class SigninApiService { } try { + if (profile.password!.startsWith('$2')) { + const newHash = await argon2.hash(password); + this.userProfilesRepository.update(user.id, { + password: newHash, + }); + } await this.userAuthService.twoFactorAuthenticate(profile, token); } catch (e) { return await fail(403, { diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts new file mode 100644 index 0000000000..9ba23c54e2 --- /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 632b0c62bc..f99e2761a3 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -4,12 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; 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 +28,9 @@ export class SignupApiService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -45,7 +48,6 @@ export class SignupApiService { private userEntityService: UserEntityService, private idService: IdService, - private metaService: MetaService, private captchaService: CaptchaService, private signupService: SignupService, private signinService: SigninService, @@ -72,31 +74,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 +108,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 +123,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 +144,7 @@ export class SignupApiService { } // メアド認証が有効の場合 - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { // メアド認証済みならエラー if (ticket.usedBy) { reply.code(400); @@ -162,7 +162,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 +172,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'); } @@ -180,8 +180,8 @@ export class SignupApiService { const code = secureRndstr(16, { chars: L_CHARS }); // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); + //const salt = await bcrypt.genSalt(8); + const hash = await argon2.hash(password); const pendingUser = await this.userPendingsRepository.insertOne({ id: this.idService.gen(), diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3c164cdd5b..a29da92138 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -105,6 +105,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'; @@ -273,6 +274,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'; @@ -517,6 +519,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], @@ -685,6 +688,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], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/steal.ts b/packages/backend/src/server/api/endpoints/admin/emoji/steal.ts index a22d784d14..48d09f6bdc 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/steal.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/steal.ts @@ -64,7 +64,7 @@ export const paramDef = { // TODO: ロジックをサービスに切り出す @Injectable() -export default class extends Endpoint { // eslint-disable-next-line import/no-default-export +export default class extends Endpoint { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 0498416b28..127c431304 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -429,6 +429,10 @@ export const meta = { type: 'number', optional: false, nullable: false, }, + enableReactionsBuffering: { + type: 'boolean', + optional: false, nullable: false, + }, notesPerOneAd: { type: 'number', optional: false, nullable: false, @@ -547,6 +551,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, + }, + }, doNotSendNotificationEmailsForAbuseReport: { type: 'boolean', optional: false, nullable: false, @@ -567,6 +583,21 @@ export const meta = { type: 'string', optional: true, nullable: true, }, + trustedLinkUrlPatterns: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + customSplashText: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, }, }, } as const; @@ -716,6 +747,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, @@ -724,11 +756,15 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + federation: instance.federation, + federationHosts: instance.federationHosts, doNotSendNotificationEmailsForAbuseReport: instance.doNotSendNotificationEmailsForAbuseReport, emailToReceiveAbuseReport: instance.emailToReceiveAbuseReport, enableReceivePrerelease: instance.enableReceivePrerelease, skipVersion: instance.skipVersion, skipCherryPickVersion: instance.skipCherryPickVersion, + trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns, + customSplashText: instance.customSplashText, }; }); } 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 acc1554289..5a5d441108 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 @@ -22,16 +22,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 add65fe335..874b858f4f 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 @@ -22,16 +22,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/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index 53db096c1d..828dbae712 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -4,7 +4,8 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -65,7 +66,7 @@ export default class extends Endpoint { // eslint- const passwd = secureRndstr(8); // Generate hash of password - const hash = bcrypt.hashSync(passwd); + const hash = await argon2.hash(passwd); await this.userProfilesRepository.update({ userId: user.id, 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 292fc2e436..f1cc3324a5 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, @@ -227,6 +231,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/system-webhook/test.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts new file mode 100644 index 0000000000..fb2ddf4b44 --- /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/unset-user-avatar.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts index ddab6f3a9d..2f4dd63a8e 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts @@ -25,7 +25,6 @@ export const paramDef = { required: ['userId'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts index e16dad719c..117ba5c7c3 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts @@ -25,7 +25,6 @@ export const paramDef = { required: ['userId'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( 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 0aca982919..ad7c604006 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -163,6 +163,7 @@ export const paramDef = { perRemoteUserUserTimelineCacheMax: { type: 'integer' }, perUserHomeTimelineCacheMax: { type: 'integer' }, perUserListTimelineCacheMax: { type: 'integer' }, + enableReactionsBuffering: { type: 'boolean' }, notesPerOneAd: { type: 'integer' }, silencedHosts: { type: 'array', @@ -188,11 +189,31 @@ 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', + }, + }, doNotSendNotificationEmailsForAbuseReport: { type: 'boolean' }, emailToReceiveAbuseReport: { type: 'string', nullable: true }, enableReceivePrerelease: { type: 'boolean' }, skipVersion: { type: 'boolean' }, skipCherryPickVersion: { type: 'string', nullable: true }, + trustedLinkUrlPatterns: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, + customSplashText: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, }, required: [], } as const; @@ -705,6 +726,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; } @@ -739,6 +764,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()); + } + if (ps.doNotSendNotificationEmailsForAbuseReport !== undefined) { set.doNotSendNotificationEmailsForAbuseReport = ps.doNotSendNotificationEmailsForAbuseReport; } @@ -759,6 +792,14 @@ export default class extends Endpoint { // eslint- set.skipCherryPickVersion = ps.skipCherryPickVersion; } + if (Array.isArray(ps.trustedLinkUrlPatterns)) { + set.trustedLinkUrlPatterns = ps.trustedLinkUrlPatterns.filter(Boolean); + } + + if (Array.isArray(ps.customSplashText)) { + set.customSplashText = ps.customSplashText.filter(Boolean); + } + 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 16ffc6101b..e3a584b865 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -40,6 +40,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: { @@ -97,7 +103,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 be7b9c2328..a5d708af31 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -33,6 +33,12 @@ export const meta = { id: '1c6b35c9-943e-48c2-81e4-2844989407f7', }, + emptyKeyword: { + message: 'Either keywords or excludeKeywords is required.', + code: 'EMPTY_KEYWORD', + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', + }, + noSuchUserGroup: { message: 'No such user group.', code: 'NO_SUCH_USER_GROUP', @@ -95,7 +101,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 d3c40dba59..c52608cefb 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'; @@ -91,7 +90,6 @@ export default class extends Endpoint { // eslint- private utilityService: UtilityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, - private metaService: MetaService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, @@ -112,9 +110,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 bd6be1783f..baf86ef862 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 7e9b0fa0e1..eb45e29f9e 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 4670392025..f58248e848 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 @@ -9,6 +9,7 @@ import type { NotesRepository, DriveFilesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -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 955314bbaf..f98d24b438 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -4,13 +4,14 @@ */ 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 { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -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, _1, _2, 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/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 65eece5b97..d87df588bf 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -55,7 +56,6 @@ export const paramDef = { required: ['password', 'name', 'credential'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( @@ -86,7 +86,7 @@ export default class extends Endpoint { } } - const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? ''); + const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); if (!passwordMatched) { throw new ApiError(meta.errors.incorrectPassword); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 9391aee5e0..5621ff3dc1 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository } from '@/models/_.js'; @@ -182,7 +183,6 @@ export const paramDef = { required: ['password'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( @@ -217,7 +217,7 @@ export default class extends Endpoint { } } - const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? ''); + const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); if (!passwordMatched) { throw new ApiError(meta.errors.incorrectPassword); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a54c598213..7283159f87 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; import { Inject, Injectable } from '@nestjs/common'; @@ -77,7 +78,7 @@ export default class extends Endpoint { // eslint- } } - const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? ''); + const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); if (!passwordMatched) { throw new ApiError(meta.errors.incorrectPassword); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index c350136eae..982cb7aee5 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; @@ -66,7 +67,7 @@ export default class extends Endpoint { // eslint- } } - const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? ''); + const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); if (!passwordMatched) { throw new ApiError(meta.errors.incorrectPassword); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index b5a53cc889..8da331505b 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -62,7 +63,7 @@ export default class extends Endpoint { // eslint- } } - const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? ''); + const passwordMatched = await argon2.verify(profile.password ?? '', ps.password); if (!passwordMatched) { throw new ApiError(meta.errors.incorrectPassword); } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts index cfa07cc8d7..deb56a3ac4 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserSecurityKeysRepository } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index bb78d47149..6aedde717c 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserProfilesRepository } from '@/models/_.js'; @@ -50,15 +51,15 @@ export default class extends Endpoint { // eslint- } } - const passwordMatched = await bcrypt.compare(ps.currentPassword, profile.password!); + const passwordMatched = await argon2.verify(profile.password!, ps.currentPassword); if (!passwordMatched) { throw new Error('incorrect password'); } // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.newPassword, salt); + //const salt = await bcrypt.genSalt(8); + const hash = await argon2.hash(ps.newPassword); await this.userProfilesRepository.update(me.id, { password: hash, diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index bfa0b4605d..af4d601ad6 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -59,7 +60,7 @@ export default class extends Endpoint { // eslint- return; } - const passwordMatched = await bcrypt.compare(ps.password, profile.password!); + const passwordMatched = await argon2.verify(profile.password!, ps.password); if (!passwordMatched) { throw new Error('incorrect password'); } 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 bc46163e3d..bdf6c065e8 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 2606108539..d7bb6bcd22 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 d5e824df27..e03192d8c6 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 0f5800404e..76b285bb7e 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 bacdd5c88f..76ecfd082c 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/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 78f3cce9ad..e1cdfdc185 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; @@ -43,7 +44,7 @@ export default class extends Endpoint { // eslint- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); // Compare password - const same = await bcrypt.compare(ps.password, profile.password!); + const same = await argon2.verify(profile.password!, ps.password); if (!same) { throw new Error('incorrect password'); 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 eea657ebbd..0be8bfb695 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; 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 +16,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 +70,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, @@ -95,7 +97,7 @@ export default class extends Endpoint { // eslint- } } - const passwordMatched = await bcrypt.compare(ps.password, profile.password!); + const passwordMatched = await argon2.verify(profile.password!, ps.password); if (!passwordMatched) { throw new ApiError(meta.errors.incorrectPassword); } @@ -105,7 +107,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 2fc90dedc2..ffe44c0507 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -4,7 +4,7 @@ */ import RE2 from 're2'; -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { JSDOM } from 'jsdom'; @@ -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 }, @@ -270,6 +270,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 9eb7f5b3a0..6e84603f7a 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 fe07afb2d0..394c178f2a 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 5ddb79caf2..4a0c09ff0c 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 0000000000..2bf6df9ce2 --- /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 fe5dfbd1e2..f02f4a1709 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'; @@ -132,6 +130,12 @@ export const meta = { code: 'CONTAINS_TOO_MANY_MENTIONS', id: '4de0363a-3046-481b-9b0f-feff3e211025', }, + + cannotScheduleDeleteEarlierThanNow: { + message: 'Cannot specify delete time earlier than now.', + code: 'CANNOT_SCHEDULE_DELETE_EARLIER_THAN_NOW', + id: '9f04994a-3aa2-11ef-a495-177eea74788f', + }, }, } as const; @@ -202,6 +206,14 @@ export const paramDef = { metadata: { type: 'object' }, }, }, + scheduledDelete: { + type: 'object', + nullable: true, + properties: { + deleteAt: { type: 'integer', nullable: true }, + deleteAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + }, }, // (re)note with text, files and poll are optional if: { @@ -373,6 +385,16 @@ export default class extends Endpoint { // eslint- } } + if (ps.scheduledDelete) { + if (typeof ps.scheduledDelete.deleteAt === 'number') { + if (ps.scheduledDelete.deleteAt < Date.now()) { + throw new ApiError(meta.errors.cannotScheduleDeleteEarlierThanNow); + } else if (typeof ps.scheduledDelete.deleteAfter === 'number') { + ps.scheduledDelete.deleteAt = Date.now() + ps.scheduledDelete.deleteAfter; + } + } + } + // 投稿を作成 try { const note = await this.noteCreateService.create(me, { @@ -402,6 +424,7 @@ export default class extends Endpoint { // eslint- apMentions: ps.noExtractMentions ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined, + deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : ps.scheduledDelete?.deleteAfter ? new Date(Date.now() + ps.scheduledDelete.deleteAfter) : null, }); return { 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 46c78fda07..965a83626d 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'; @@ -75,6 +74,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, @@ -88,7 +90,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) => { @@ -102,9 +103,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, @@ -158,7 +157,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, withCats: ps.withCats, 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 60ba408f4d..5ce6657ed8 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'; @@ -67,6 +65,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, @@ -74,10 +75,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); @@ -90,9 +89,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, @@ -117,7 +114,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 b3aa2973f6..1e98b48925 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 = { @@ -57,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, @@ -70,15 +72,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, @@ -110,7 +109,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 a1a135601e..028ecea37b 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -5,16 +5,17 @@ import { URLSearchParams } from 'node:url'; import fs from 'node:fs'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { translate } from '@vitalets/google-translate-api'; import { TranslationServiceClient } from '@google-cloud/translate'; 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 { createTemp } from '@/misc/create-temp.js'; import { RoleService } from '@/core/RoleService.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -68,9 +69,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, ) { @@ -93,15 +96,13 @@ export default class extends Endpoint { // eslint- return; } - const instance = await this.metaService.fetch(); - const translatorServices = [ 'deepl', 'google_no_api', 'ctav3', ]; - if (instance.translatorType == null || !translatorServices.includes(instance.translatorType)) { + if (this.serverSettings.translatorType == null || !translatorServices.includes(this.serverSettings.translatorType)) { throw new ApiError(meta.errors.noTranslateService); } @@ -109,12 +110,12 @@ export default class extends Endpoint { // eslint- if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; let translationResult; - if (instance.translatorType === 'deepl') { - if (instance.deeplAuthKey == null) { + if (this.serverSettings.translatorType === 'deepl') { + if (this.serverSettings.deeplAuthKey == null) { throw new ApiError(meta.errors.unavailable); } - translationResult = await this.translateDeepL((note.cw ? note.cw + '\n' : '') + note.text, targetLang, instance.deeplAuthKey, instance.deeplIsPro, instance.translatorType); - } else if (instance.translatorType === 'google_no_api') { + translationResult = await this.translateDeepL((note.cw ? note.cw + '\n' : '') + note.text, targetLang, this.serverSettings.deeplAuthKey, this.serverSettings.deeplIsPro, this.serverSettings.translatorType); + } else if (this.serverSettings.translatorType === 'google_no_api') { let targetLang = ps.targetLang; if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; @@ -123,14 +124,14 @@ export default class extends Endpoint { // eslint- return { sourceLang: raw.src, text: text, - translator: translatorServices, + translator: this.serverSettings.translatorType, // 修正点: 配列ではなく単一の文字列 }; - } else if (instance.translatorType === 'ctav3') { - if (instance.ctav3SaKey == null) return Promise.resolve(204); - else if (instance.ctav3ProjectId == null) return Promise.resolve(204); - else if (instance.ctav3Location == null) return Promise.resolve(204); + } else if (this.serverSettings.translatorType === 'ctav3') { + if (this.serverSettings.ctav3SaKey == null) return Promise.resolve(204); + else if (this.serverSettings.ctav3ProjectId == null) return Promise.resolve(204); + else if (this.serverSettings.ctav3Location == null) return Promise.resolve(204); translationResult = await this.apiCloudTranslationAdvanced( - (note.cw ? note.cw + '\n' : '') + note.text, targetLang, instance.ctav3SaKey, instance.ctav3ProjectId, instance.ctav3Location, instance.ctav3Model, instance.ctav3Glossary, instance.translatorType, + (note.cw ? note.cw + '\n' : '') + note.text, targetLang, this.serverSettings.ctav3SaKey, this.serverSettings.ctav3ProjectId, this.serverSettings.ctav3Location, this.serverSettings.ctav3Model, this.serverSettings.ctav3Glossary, this.serverSettings.translatorType, ); } else { throw new Error('Unsupported translator type'); 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 42a89b004a..96695f214f 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'; @@ -70,6 +68,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, @@ -81,11 +82,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); @@ -100,9 +99,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, @@ -126,7 +123,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/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 15832ef7f8..5b0b656c63 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/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index 84a1f010d4..8b0fee6611 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -8,9 +8,9 @@ import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; -import { UserRenoteMutingService } from "@/core/UserRenoteMutingService.js"; +import { UserRenoteMutingService } from '@/core/UserRenoteMutingService.js'; import type { RenoteMutingsRepository } from '@/models/_.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts index 1a584b8404..57b512fc8c 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { ApiError } from '../../error.js'; -import { UserRenoteMutingService } from "@/core/UserRenoteMutingService.js"; +import { UserRenoteMutingService } from '@/core/UserRenoteMutingService.js'; import type { RenoteMutingsRepository } from '@/models/_.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts index 9693892637..1639b57bc5 100644 --- a/packages/backend/src/server/api/endpoints/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import bcrypt from 'bcryptjs'; +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; import { Inject, Injectable } from '@nestjs/common'; import type { UserProfilesRepository, PasswordResetRequestsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -53,8 +54,8 @@ export default class extends Endpoint { // eslint- } // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(ps.password, salt); + //const salt = await bcrypt.genSalt(8); + const hash = await argon2.hash(ps.password); await this.userProfilesRepository.update(req.userId, { password: hash, diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index c13802eb06..8301c85f2e 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 a9a33149f9..fd76df2d3c 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 affb0996f1..4944be9b05 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/flashs.ts b/packages/backend/src/server/api/endpoints/users/flashs.ts index e5ea450215..c02835a3ee 100644 --- a/packages/backend/src/server/api/endpoints/users/flashs.ts +++ b/packages/backend/src/server/api/endpoints/users/flashs.ts @@ -37,7 +37,6 @@ export const paramDef = { required: ['userId'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index f1b34ced5f..a05f94247d 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'; @@ -68,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, @@ -76,15 +78,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 @@ -95,7 +94,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/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts index e4175cf7ef..30219f52c0 100644 --- a/packages/backend/src/server/api/endpoints/users/stats.ts +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -123,7 +123,6 @@ export const paramDef = { required: ['userId'], } as const; -// eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( diff --git a/packages/backend/src/server/api/endpoints/users/translate.ts b/packages/backend/src/server/api/endpoints/users/translate.ts index 8a6b7b14fe..e2a9b013a1 100644 --- a/packages/backend/src/server/api/endpoints/users/translate.ts +++ b/packages/backend/src/server/api/endpoints/users/translate.ts @@ -5,15 +5,16 @@ import { URLSearchParams } from 'node:url'; import fs from 'node:fs'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { translate } from '@vitalets/google-translate-api'; import { TranslationServiceClient } from '@google-cloud/translate'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { createTemp } from '@/misc/create-temp.js'; import { RoleService } from '@/core/RoleService.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -62,8 +63,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 getterService: GetterService, - private metaService: MetaService, private httpRequestService: HttpRequestService, private roleService: RoleService, ) { @@ -82,15 +85,13 @@ export default class extends Endpoint { // eslint- return; } - const instance = await this.metaService.fetch(); - const translatorServices = [ 'deepl', 'google_no_api', 'ctav3', ]; - if (instance.translatorType == null || !translatorServices.includes(instance.translatorType)) { + if (this.serverSettings.translatorType == null || !translatorServices.includes(this.serverSettings.translatorType)) { return Promise.resolve(204); // Promise.resolveで204をラップする } @@ -98,12 +99,12 @@ export default class extends Endpoint { // eslint- if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; let translationResult; - if (instance.translatorType === 'deepl') { - if (instance.deeplAuthKey == null) { + if (this.serverSettings.translatorType === 'deepl') { + if (this.serverSettings.deeplAuthKey == null) { throw new ApiError(meta.errors.unavailable); } - translationResult = await this.translateDeepL(target.description, targetLang, instance.deeplAuthKey, instance.deeplIsPro, instance.translatorType); - } else if (instance.translatorType === 'google_no_api') { + translationResult = await this.translateDeepL(target.description, targetLang, this.serverSettings.deeplAuthKey, this.serverSettings.deeplIsPro, this.serverSettings.translatorType); + } else if (this.serverSettings.translatorType === 'google_no_api') { let targetLang = ps.targetLang; if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; @@ -112,14 +113,14 @@ export default class extends Endpoint { // eslint- return { sourceLang: raw.src, text: text, - translator: instance.translatorType, // 修正点: 配列ではなく単一の文字列 + translator: this.serverSettings.translatorType, // 修正点: 配列ではなく単一の文字列 }; - } else if (instance.translatorType === 'ctav3') { - if (instance.ctav3SaKey == null) return Promise.resolve(204); - else if (instance.ctav3ProjectId == null) return Promise.resolve(204); - else if (instance.ctav3Location == null) return Promise.resolve(204); + } else if (this.serverSettings.translatorType === 'ctav3') { + if (this.serverSettings.ctav3SaKey == null) return Promise.resolve(204); + else if (this.serverSettings.ctav3ProjectId == null) return Promise.resolve(204); + else if (this.serverSettings.ctav3Location == null) return Promise.resolve(204); translationResult = await this.apiCloudTranslationAdvanced( - target.description, targetLang, instance.ctav3SaKey, instance.ctav3ProjectId, instance.ctav3Location, instance.ctav3Model, instance.ctav3Glossary, instance.translatorType, + target.description, targetLang, this.serverSettings.ctav3SaKey, this.serverSettings.ctav3ProjectId, this.serverSettings.ctav3Location, this.serverSettings.ctav3Model, this.serverSettings.ctav3Glossary, this.serverSettings.translatorType, ); } else { throw new Error('Unsupported translator type'); diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index d921100f8a..40131ca4db 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -13,8 +13,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { openapi: '3.1.0', info: { - version: config.version, - description: config.basedMisskeyVersion, + version: `${config.version} (${config.basedMisskeyVersion})`, title: 'CherryPick API', }, 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 a0a6af18ba..1587c18017 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { reversiUpdateKeys } from 'cherrypick-js'; import type { MiReversiGame } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -12,7 +13,6 @@ import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityServi 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 'cherrypick-js'; class ReversiGameChannel extends Channel { public readonly chName = 'reversiGame'; @@ -62,6 +62,7 @@ class ReversiGameChannel extends Channel { this.putStone(body.pos, body.id); break; case 'claimTimeIsUp': this.claimTimeIsUp(); break; + case 'reaction': this.sendReaction(body); break; } } @@ -100,6 +101,13 @@ class ReversiGameChannel extends Channel { this.reversiService.checkTimeout(this.gameId!); } + @bindThis + private async sendReaction(reaction: string) { + if (this.user == null) return; + + this.reversiService.sendReaction(this.gameId!, this.user, reaction); + } + @bindThis public dispose() { // Unsubscribe events diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 46a999218e..bb6d9bf163 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, @@ -34,6 +33,7 @@ import type { SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, + ScheduledNoteDeleteQueue, } from '@/core/QueueModule.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -61,7 +61,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 +73,9 @@ export class ClientServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -108,7 +112,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, @@ -122,38 +125,37 @@ export class ClientServerService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, + @Inject('queue:scheduledNoteDelete') public scheduledNoteDeleteQueue: ScheduledNoteDeleteQueue, ) { //this.createServer = this.createServer.bind(this); } @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': '#95e3e8', // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'theme_color': instance.themeColor || '#ffa9c3', + 'theme_color': this.meta.themeColor || '#ffa9c3', '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 +179,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'); @@ -197,6 +199,7 @@ export class ClientServerService { instanceUrl: this.config.url, metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)), now: Date.now(), + customSplashText: meta.customSplashText[Math.floor(Math.random() * meta.customSplashText.length)], }; } @@ -239,7 +242,7 @@ export class ClientServerService { } }); - const serverAdapter = new FastifyAdapter(); + const bullBoardServerAdapter = new BullBoardFastifyAdapter(); createBullBoard({ queues: [ @@ -251,12 +254,13 @@ export class ClientServerService { this.objectStorageQueue, this.userWebhookDeliverQueue, this.systemWebhookDeliverQueue, + this.scheduledNoteDeleteQueue, ].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, { @@ -278,15 +282,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(); }); @@ -297,6 +308,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 @@ -426,15 +444,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 ?? 'CherryPick'; + const name = this.meta.name ?? 'CherryPick'; let content = ''; content += ''; content += `${name}`; @@ -451,14 +474,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 ?? 'CherryPick', - desc: meta.description, - ...await this.generateCommonPugData(meta), + title: this.meta.name ?? 'CherryPick', + desc: this.meta.description, + ...await this.generateCommonPugData(this.meta), ...data, }); }; @@ -536,7 +558,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?:/)) @@ -552,7 +573,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 { // リモートユーザーなので @@ -590,7 +611,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'); @@ -602,7 +622,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); @@ -627,7 +647,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 { @@ -641,7 +660,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); @@ -657,7 +676,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'); @@ -667,7 +685,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); @@ -683,7 +701,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'); @@ -693,7 +710,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); @@ -707,7 +724,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'); @@ -717,7 +733,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); @@ -732,11 +748,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); @@ -751,11 +766,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); @@ -763,7 +777,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 }); @@ -773,22 +787,98 @@ 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 ?? 'CherryPick', + ...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 ?? 'CherryPick', + ...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 ?? 'CherryPick', + ...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 ?? 'CherryPick', + ...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, basedMisskeyVersion: this.config.basedMisskeyVersion, 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/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index d0b41f6c55..086dda04d6 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, IsNull } from 'typeorm'; import { Feed } from 'feed'; +import { parse as mfmParse } from 'cfm-js'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, NotesRepository, UserProfilesRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -14,8 +15,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import { MfmService } from "@/core/MfmService.js"; -import { parse as mfmParse } from 'cherrypick-mfm-js'; +import { MfmService } from '@/core/MfmService.js'; @Injectable() export class FeedService { diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 8f8f08a305..5d493c2c46 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/bios.js b/packages/backend/src/server/web/bios.js index 9ff5dca72a..e9e5ddb101 100644 --- a/packages/backend/src/server/web/bios.js +++ b/packages/backend/src/server/web/bios.js @@ -19,7 +19,7 @@ window.onload = async () => { method: 'POST', body: JSON.stringify(data), credentials: 'omit', - cache: 'no-cache' + cache: 'no-cache', }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); 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 0000000000..e008080e7d --- /dev/null +++ b/packages/backend/src/server/web/boot.embed.js @@ -0,0 +1,227 @@ +/* + * 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; + const basedMisskeyVersion = meta.basedMisskeyVersion; + if (v == null) { + renderError('META_FETCH_V'); + return; + } + + if (basedMisskeyVersion == null) { + renderError('META_FETCH_BASEDMISSKEY_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 CherryPick
+
Error Code: ${code}
+ `; + addStyle(` + #cherrypick_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 5d5091c135..3fcf28ce58 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'; // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので @@ -176,7 +165,7 @@ if (!errorsElement) { document.body.innerHTML = ` - + @@ -186,9 +175,9 @@ Reload / リロード / 새로고침

The following actions may solve the problem. / 以下を行うと解決する可能性があります。/ 아래 과정을 진행하면 해결될 수도 있어요.

-

Clear the browser cache / ブラウザのキャッシュをクリアする / 브라우저의 캐시 지우기

Update your os and browser / ブラウザおよびOSを最新バージョンに更新する / 브라우저와 OS를 최신 버전으로 업데이트 하기

Disable an adblocker / アドブロッカーを無効にする / 광고 차단기를 비활성화 하기

+

Clear the browser cache / ブラウザのキャッシュをクリアする / 브라우저의 캐시 지우기

(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する / dom.webaudio.enabled를 true로 설정하기

Other options / その他のオプション / 기타 옵션 @@ -222,7 +211,7 @@ ERROR CODE: ${code} - ${JSON.stringify(details)}`; + ${details.toString()} ${JSON.stringify(details)}`; errorsElement.appendChild(detailsElement); addStyle(` * { @@ -334,6 +323,6 @@ #errorInfo { width: 50%; } - }`) + }`); } })(); diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js index 72541a6732..2db6f01372 100644 --- a/packages/backend/src/server/web/cli.js +++ b/packages/backend/src/server/web/cli.js @@ -17,12 +17,12 @@ window.onload = async () => { // Send request fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, method: 'POST', body: JSON.stringify(data), credentials: 'omit', - cache: 'no-cache' + cache: 'no-cache', }).then(async (res) => { const body = res.status === 204 ? null : await res.json(); @@ -41,7 +41,7 @@ window.onload = async () => { document.getElementById('submit').addEventListener('click', () => { api('notes/create', { - text: document.getElementById('text').value + text: document.getElementById('text').value, }).then(() => { location.reload(); }); @@ -66,7 +66,6 @@ window.onload = async () => { } text.textContent += `${note.text || ''}`; - el.appendChild(name); el.appendChild(text); tl.appendChild(el); diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index e1ba956168..179578c8a0 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -35,6 +35,17 @@ html { border-radius: 22px; } +#splashText { + position: absolute; + inset: 0; + margin: auto; + display: inline-block; + inline-size: 70%; + block-size: 0; + text-align: center; + padding-block-start: 200px; +} + #splashSpinner { position: absolute; top: 0; @@ -45,9 +56,10 @@ html { display: inline-block; width: 28px; height: 28px; - transform: translateY(70px); + transform: translateY(80px); 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 0000000000..8e9726998b --- /dev/null +++ b/packages/backend/src/server/web/style.embed.css @@ -0,0 +1,110 @@ +/* + * 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; + border-radius: 22px; +} + +#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; + animation: splashSpinner 2s linear infinite; +} +#splashSpinner > .spinner > .path { + stroke: var(--accent); + stroke-linecap: round; + animation: dash 1.2s ease-in-out infinite; +} + +@keyframes splashSpinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + 0% { + stroke-dasharray: 1, 150; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -35; + } + 100% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -124; + } +} 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 0000000000..9dae58dfbf --- /dev/null +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -0,0 +1,71 @@ +block vars + +block loadClientEntry + - const entry = config.frontendEmbedEntry; + +doctype html + +html(class='embed') + + head + meta(charset='utf-8') + meta(name='application-name' content='CherryPick') + meta(name='referrer' content='origin') + meta(name='theme-color' content= themeColor || '#ffbcdc') + meta(name='theme-color-orig' content= themeColor || '#ffbcdc') + meta(property='og:site_name' content= instanceName || 'CherryPick') + 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 || 'CherryPick' + + block meta + meta(name='robots' content='noindex') + + style + include ../style.embed.css + + script. + var VERSION = "#{version}"; + var BASED_MISSKEY_VERSION = "#{basedMisskeyVersion}"; + var CLIENT_ENTRY = "#{entry.file}"; + + script(type='application/json' id='cherrypick_meta' data-generated-at=now) + != metaJson + + script(type='application/json' id='cherrypick_embedCtx' data-generated-at=now) + != embedCtx + + script + include ../boot.embed.js + + body + noscript: p + | JavaScriptを有効にしてください + br + | Please turn on your JavaScript + br + | JavaScript를 활성화해주세요 + div#splash + img#splashIcon(src= icon || '/static-assets/splash.png') + span#splashText + block customSplashText + = customSplashText + 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 ec852d95d8..2a3d9c09e4 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 @@ -36,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.${version}/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 @@ -70,9 +68,10 @@ html script. var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{clientEntry.file}"; + var BASED_MISSKEY_VERSION = "#{basedMisskeyVersion}"; + var CLIENT_ENTRY = "#{entry.file}"; - script(type='application/json' id='misskey_meta' data-generated-at=now) + script(type='application/json' id='cherrypick_meta' data-generated-at=now) != metaJson script @@ -87,6 +86,9 @@ html | JavaScript를 활성화해주세요 div#splash img#splashIcon(src= icon || '/static-assets/splash.png') + span#splashText + block customSplashText + = customSplashText div#splashSpinner diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8735dc6e2a..805d060919 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,6 +17,7 @@ * groupInvited - グループに招待された * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 + * exportCompleted - エクスポートが完了 * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -34,6 +35,7 @@ export const notificationTypes = [ 'groupInvited', 'roleAssigned', 'achievementEarned', + 'exportCompleted', 'app', 'test', ] as const; @@ -53,6 +55,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', diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts index 866a7e1f5b..953d48c061 100644 --- a/packages/backend/test-server/entry.ts +++ b/packages/backend/test-server/entry.ts @@ -2,6 +2,7 @@ import { portToPid } from 'pid-port'; import fkill from 'fkill'; import Fastify from 'fastify'; import { NestFactory } from '@nestjs/core'; +import { INestApplicationContext } from '@nestjs/common'; import { MainModule } from '@/MainModule.js'; import { ServerService } from '@/server/ServerService.js'; import { loadConfig } from '@/config.js'; @@ -12,6 +13,9 @@ 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/2fa.ts b/packages/backend/test/e2e/2fa.ts index 1d04396406..f26e45f6d3 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -9,7 +9,6 @@ import * as assert from 'assert'; import * as crypto from 'node:crypto'; import cbor from 'cbor'; import * as OTPAuth from 'otpauth'; -import { loadConfig } from '@/config.js'; import { api, signup } from '../utils.js'; import type { AuthenticationResponseJSON, @@ -20,6 +19,7 @@ import type { RegistrationResponseJSON, } from '@simplewebauthn/types'; import type * as misskey from 'cherrypick-js'; +import { loadConfig } from '@/config.js'; describe('2要素認証', () => { let alice: misskey.entities.SignupResponse; diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 1c2606271b..c82b9498cb 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -6,7 +6,6 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, failedApiCall, @@ -19,6 +18,7 @@ import { userList, } from '../utils.js'; import type * as misskey from 'cherrypick-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); @@ -231,6 +231,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) @@ -258,6 +269,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/clips.ts b/packages/backend/test/e2e/clips.ts index d38deb18b0..542c1cce18 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -6,9 +6,9 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, ApiRequest, failedApiCall, hiddenNote, post, signup, successfulApiCall } from '../utils.js'; import type * as Misskey from 'cherrypick-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; type Optional = Pick, K> & Omit; diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index f9259ea27b..556ae35eb2 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -9,9 +9,9 @@ import * as assert from 'assert'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; -import { MiUser } from '@/models/_.js'; import { api, castAsError, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; import type * as misskey from 'cherrypick-js'; +import { MiUser } from '@/models/_.js'; describe('Endpoints', () => { let alice: misskey.entities.SignupResponse; @@ -1121,7 +1121,7 @@ describe('Endpoints', () => { userId: bob.id, }, alice); assert.strictEqual(res1.status, 204); - assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo); + assert.strictEqual((res2.body as unknown as { memo: string }).memo, memo); }); test('自分に関するメモを更新できる', async () => { @@ -1136,7 +1136,7 @@ describe('Endpoints', () => { userId: alice.id, }, alice); assert.strictEqual(res1.status, 204); - assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo); + assert.strictEqual((res2.body as unknown as { memo: string }).memo, memo); }); test('メモを削除できる', async () => { diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 5c5a9c1f1a..d1f0846507 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -156,20 +156,20 @@ describe('Webリソース', () => { describe(' has entry such ', () => { beforeEach(() => { - post(alice, { text: "**a**" }) + post(alice, { text: '**a**' }); }); test('MFMを含まない。', async () => { - const content = await simpleGet(path(alice.username), "*/*", undefined, res => res.text()); + const content = await simpleGet(path(alice.username), '*/*', undefined, res => res.text()); const _body: unknown = content.body; // JSONフィードのときは改めて文字列化する - const body: string = typeof (_body) === "object" ? JSON.stringify(_body) : _body as string; + const body: string = typeof (_body) === 'object' ? JSON.stringify(_body) : _body as string; - if (body.includes("**a**")) { - throw new Error("MFM shouldn't be included"); + if (body.includes('**a**')) { + throw new Error('MFM shouldn\'t be included'); } }); - }) + }); }); describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { diff --git a/packages/backend/test/e2e/fetch-validate-ap-deny.ts b/packages/backend/test/e2e/fetch-validate-ap-deny.ts index 75d60b40bd..51d8d5c5a3 100644 --- a/packages/backend/test/e2e/fetch-validate-ap-deny.ts +++ b/packages/backend/test/e2e/fetch-validate-ap-deny.ts @@ -5,9 +5,9 @@ process.env.NODE_ENV = 'test'; -import { validateContentTypeSetAsActivityPub, validateContentTypeSetAsJsonLD } from '@/core/activitypub/misc/validator.js'; import { signup, uploadFile, relativeFetch } from '../utils.js'; import type * as misskey from 'cherrypick-js'; +import { validateContentTypeSetAsActivityPub, validateContentTypeSetAsJsonLD } from '@/core/activitypub/misc/validator.js'; describe('validateContentTypeSetAsActivityPub/JsonLD (deny case)', () => { let alice: misskey.entities.SignupResponse; diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts index 2aee9039e2..8b50b0954d 100644 --- a/packages/backend/test/e2e/move.ts +++ b/packages/backend/test/e2e/move.ts @@ -9,12 +9,12 @@ process.env.NODE_ENV = 'test'; import { setTimeout } from 'node:timers/promises'; import * as assert from 'assert'; +import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js'; +import type * as misskey from 'cherrypick-js'; import { loadConfig } from '@/config.js'; import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { jobQueue } from '@/boot/common.js'; -import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js'; -import type * as misskey from 'cherrypick-js'; describe('Account Move', () => { let jq: INestApplicationContext; diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index b1effa74bc..d93007b779 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Repository } from "typeorm"; +import type { Repository } from 'typeorm'; process.env.NODE_ENV = 'test'; import * as assert from 'assert'; +import type * as misskey from 'cherrypick-js'; import { MiNote } from '@/models/Note.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { api, castAsError, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; -import type * as misskey from 'cherrypick-js'; describe('Note', () => { let Notes: Repository; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index b0598fd578..e4e21b61ca 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 'cherrypick-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, @@ -351,6 +353,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); @@ -415,6 +418,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 861bc6db66..9bde512026 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 3c7e796700..8ca3a80470 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -7,21 +7,22 @@ import type { Config } from '@/config.js'; import type { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import type { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import type { ApRequestService } from '@/core/activitypub/ApRequestService.js'; -import { Resolver } from '@/core/activitypub/ApResolverService.js'; import type { IObject } from '@/core/activitypub/type.js'; import type { HttpRequestService } from '@/core/HttpRequestService.js'; import type { InstanceActorService } from '@/core/InstanceActorService.js'; import type { LoggerService } from '@/core/LoggerService.js'; import type { MetaService } from '@/core/MetaService.js'; import type { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; import type { FollowRequestsRepository, + MiMeta, NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository, } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { Resolver } from '@/core/activitypub/ApResolverService.js'; type MockResponse = { type: string; @@ -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/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts index e971659070..93c87fd0c2 100644 --- a/packages/backend/test/unit/AbuseReportNotificationService.ts +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -5,6 +5,7 @@ import { jest } from '@jest/globals'; import { Test, TestingModule } from '@nestjs/testing'; +import { randomString } from '../utils.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { AbuseReportNotificationRecipientRepository, @@ -25,7 +26,6 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; -import { randomString } from '../utils.js'; process.env.NODE_ENV = 'test'; diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 81da0fac31..d2eb83e75b 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -8,9 +8,6 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; -import { GlobalModule } from '@/GlobalModule.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; -import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, @@ -18,6 +15,11 @@ import type { MiUser, UsersRepository, } from '@/models/_.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { DI } from '@/di-symbols.js'; import { genAidx } from '@/misc/id/aidx.js'; import { CacheService } from '@/core/CacheService.js'; @@ -25,8 +27,6 @@ import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts index 964c65ccaa..ce17246bd1 100644 --- a/packages/backend/test/unit/DriveService.ts +++ b/packages/backend/test/unit/DriveService.ts @@ -14,10 +14,10 @@ import { S3Client, } from '@aws-sdk/client-s3'; import { mockClient } from 'aws-sdk-client-mock'; +import type { TestingModule } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { DriveService } from '@/core/DriveService.js'; import { CoreModule } from '@/core/CoreModule.js'; -import type { TestingModule } from '@nestjs/testing'; describe('DriveService', () => { let app: TestingModule; diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index bf8f3ab0e3..b944794649 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; import { Redis } from 'ioredis'; +import type { TestingModule } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; function mockRedis() { const hash = {} as any; diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index 29bd03a201..3e5d669875 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -11,13 +11,13 @@ import { dirname } from 'node:path'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { afterAll, beforeAll, describe, test } from '@jest/globals'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfo, FileInfoService } from '@/core/FileInfoService.js'; //import { DI } from '@/di-symbols.js'; import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -34,9 +34,9 @@ describe('FileInfoService', () => { delete fi.sensitive; delete fi.blurhash; delete fi.porn; - + return fi; - } + }; beforeAll(async () => { app = await Test.createTestingModule({ diff --git a/packages/backend/test/unit/MetaService.ts b/packages/backend/test/unit/MetaService.ts index 19c98eab3d..7a66b42e11 100644 --- a/packages/backend/test/unit/MetaService.ts +++ b/packages/backend/test/unit/MetaService.ts @@ -7,12 +7,12 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DataSource } from 'typeorm'; import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { CoreModule } from '@/core/CoreModule.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { DataSource } from 'typeorm'; describe('MetaService', () => { let app: TestingModule; diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index 2bbe9a907a..a36a5cfcf1 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -4,7 +4,7 @@ */ import * as assert from 'assert'; -import * as mfm from 'cherrypick-mfm-js'; +import * as mfm from 'cfm-js'; import { Test } from '@nestjs/testing'; import { CoreModule } from '@/core/CoreModule.js'; diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 9676abf07b..fe21f7c4a7 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -8,6 +8,9 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; +import type { RelaysRepository } from '@/models/_.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RelayService } from '@/core/RelayService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -15,10 +18,7 @@ import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import { IdService } from '@/core/IdService.js'; -import type { RelaysRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index b6cbe4c520..6bf0b5275c 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -10,9 +10,12 @@ import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { + MiMeta, MiRole, MiRoleAssignment, MiUser, @@ -30,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; import { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); @@ -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/S3Service.ts b/packages/backend/test/unit/S3Service.ts index 9cde506ea7..3b8a816ac8 100644 --- a/packages/backend/test/unit/S3Service.ts +++ b/packages/backend/test/unit/S3Service.ts @@ -14,11 +14,11 @@ import { UploadPartCommand, } from '@aws-sdk/client-s3'; import { mockClient } from 'aws-sdk-client-mock'; +import type { TestingModule } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { S3Service } from '@/core/S3Service.js'; import { MiMeta } from '@/models/_.js'; -import type { TestingModule } from '@nestjs/testing'; describe('S3Service', () => { let app: TestingModule; diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts new file mode 100644 index 0000000000..bae2b88c60 --- /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 790cd1490e..5401dd74d8 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 0000000000..0e88835a02 --- /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 0000000000..5e63b86f8f --- /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 6b095371ed..76c8ac685c 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -9,6 +9,9 @@ import * as assert from 'assert'; import { Test } from '@nestjs/testing'; import { jest } from '@jest/globals'; +import { MockResolver } from '../misc/mock-resolver.js'; +import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; +import type { MiRemoteUser } from '@/models/User.js'; import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; @@ -19,15 +22,11 @@ import { GlobalModule } from '@/GlobalModule.js'; 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, 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'; const host = 'https://host1.test'; @@ -107,7 +106,14 @@ describe('ActivityPub', () => { sensitiveWords: [] as string[], prohibitedWords: [] as string[], } as MiMeta; - let meta = metaInitial; + const 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({ @@ -120,11 +126,8 @@ describe('ActivityPub', () => { }; }, }) - .overrideProvider(MetaService).useValue({ - async fetch(): Promise { - return meta; - }, - }).compile(); + .overrideProvider(DI.meta).useFactory({ factory: () => meta }) + .compile(); await app.init(); app.enableShutdownHooks(); @@ -367,7 +370,7 @@ describe('ActivityPub', () => { }); test('cacheRemoteFiles=false disables caching', async () => { - meta = { ...metaInitial, cacheRemoteFiles: false }; + updateMeta({ ...metaInitial, cacheRemoteFiles: false }); const imageObject: IApDocument = { type: 'Document', @@ -396,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', diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 9dedd3a79d..63d08dabc1 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -9,6 +9,7 @@ import * as assert from 'assert'; import { jest } from '@jest/globals'; import * as lolex from '@sinonjs/fake-timers'; import { DataSource } from 'typeorm'; +import type { AppLockService } from '@/core/AppLockService.js'; import TestChart from '@/core/chart/charts/test.js'; import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; import TestUniqueChart from '@/core/chart/charts/test-unique.js'; @@ -18,7 +19,6 @@ import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/t import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js'; import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; import { loadConfig } from '@/config.js'; -import type { AppLockService } from '@/core/AppLockService.js'; import Logger from '@/logger.js'; describe('Chart', () => { diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index ee16d421c4..e4f42809f8 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/extract-mentions.ts b/packages/backend/test/unit/extract-mentions.ts index bd9d818565..63b91afbc1 100644 --- a/packages/backend/test/unit/extract-mentions.ts +++ b/packages/backend/test/unit/extract-mentions.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; -import { parse } from 'cherrypick-mfm-js'; +import { parse } from 'cfm-js'; import { extractMentions } from '@/misc/extract-mentions.js'; describe('Extract mentions', () => { diff --git a/packages/cherrypick-js/biome.json b/packages/cherrypick-js/biome.json new file mode 100644 index 0000000000..fae2bf52ce --- /dev/null +++ b/packages/cherrypick-js/biome.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noBannedTypes": "error", + "noExtraBooleanCast": "error", + "noMultipleSpacesInRegularExpressionLiterals": "error", + "noUselessCatch": "error", + "noUselessTypeConstraint": "error", + "noWith": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "warn", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "warn", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "off", + "noInvalidConstructorSuper": "error", + "noNewSymbol": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedVariables": "error", + "useArrayLiterals": "off", + "useIsNan": "error", + "useValidForDirection": "error", + "useYield": "error" + }, + "style": { + "noDefaultExport": "warn", + "noInferrableTypes": "warn", + "noNamespace": "error", + "noNonNullAssertion": "warn", + "noParameterAssign": "warn", + "noVar": "error", + "useAsConstAssertion": "error" + }, + "suspicious": { + "noAsyncPromiseExecutor": "off", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noControlCharactersInRegex": "warn", + "noDebugger": "error", + "noDoubleEquals": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "off", + "noExplicitAny": "warn", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noMisleadingCharacterClass": "error", + "noMisleadingInstantiator": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noUnsafeDeclarationMerging": "error", + "noUnsafeNegation": "error", + "useGetterReturn": "error", + "useValidTypeof": "error" + } + }, + "ignore": [ + "**/node_modules", + "built", + "coverage", + "jest.config.ts", + "test", + "test-d", + "generator" + ] + }, + "overrides": [ + { + "include": ["*.ts", "*.tsx"], + "linter": { + "rules": { + "correctness": { + "noConstAssign": "off", + "noGlobalObjectCalls": "off", + "noInvalidConstructorSuper": "off", + "noInvalidNewBuiltin": "off", + "noNewSymbol": "off", + "noSetterReturn": "off", + "noUndeclaredVariables": "off", + "noUnreachable": "off", + "noUnreachableSuper": "off" + }, + "style": { + "noArguments": "error", + "noVar": "error", + "useConst": "error" + }, + "suspicious": { + "noDuplicateClassMembers": "off", + "noDuplicateObjectKeys": "off", + "noDuplicateParameters": "off", + "noFunctionAssign": "off", + "noImportAssign": "off", + "noRedeclare": "off", + "noUnsafeNegation": "off", + "useGetterReturn": "off" + } + } + } + } + ] +} diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index dc2222f76e..af0637b38e 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -388,6 +388,9 @@ type AdminSystemWebhookShowRequest = operations['admin___system-webhook___show'] // @public (undocumented) type AdminSystemWebhookShowResponse = operations['admin___system-webhook___show']['responses']['200']['content']['application/json']; +// @public (undocumented) +type AdminSystemWebhookTestRequest = operations['admin___system-webhook___test']['requestBody']['content']['application/json']; + // @public (undocumented) type AdminSystemWebhookUpdateRequest = operations['admin___system-webhook___update']['requestBody']['content']['application/json']; @@ -581,7 +584,7 @@ type Channel = components['schemas']['Channel']; // Warning: (ae-forgotten-export) The symbol "AnyOf" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export abstract class ChannelConnection = AnyOf> extends EventEmitter { +export abstract class ChannelConnection = AnyOf> extends EventEmitter implements IChannelConnection { constructor(stream: Stream, channel: string, name?: string); // (undocumented) channel: string; @@ -723,7 +726,7 @@ export type Channels = { }; hashtag: { params: { - q?: string; + q: string[][]; }; events: { note: (payload: Note) => void; @@ -832,6 +835,10 @@ export type Channels = { value: ReversiGameDetailed[K]; }) => void; log: (payload: Record) => void; + reacted: (payload: { + userId: User['id']; + reaction: string; + }) => void; }; receives: { putStone: { @@ -842,6 +849,7 @@ export type Channels = { cancel: null | Record; updateSettings: ReversiUpdateSettings; claimTimeIsUp: null | Record; + reaction: string; }; }; }; @@ -1212,6 +1220,10 @@ export type Endpoints = Overwrite = AnyOf> extends EventEmitter { + // (undocumented) + channel: string; + // (undocumented) + dispose(): void; + // (undocumented) + id: string; + // (undocumented) + inCount: number; + // (undocumented) + name?: string; + // (undocumented) + outCount: number; + // (undocumented) + send(type: T, body: Channel['receives'][T]): void; +} + // @public (undocumented) type IClaimAchievementRequest = operations['i___claim-achievement']['requestBody']['content']['application/json']; @@ -2389,6 +2423,40 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200 // @public (undocumented) function isPureRenote(note: Note): note is PureRenote; +// @public (undocumented) +export interface IStream extends EventEmitter { + // (undocumented) + close(): void; + // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts + // + // (undocumented) + disconnectToChannel(connection: NonSharedConnection): void; + // (undocumented) + heartbeat(): void; + // (undocumented) + ping(): void; + // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts + // + // (undocumented) + removeSharedConnection(connection: SharedConnection): void; + // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts + // + // (undocumented) + removeSharedConnectionPool(pool: Pool): void; + // (undocumented) + send(typeOrPayload: string): void; + // (undocumented) + send(typeOrPayload: string, payload: unknown): void; + // (undocumented) + send(typeOrPayload: Record | unknown[]): void; + // (undocumented) + send(typeOrPayload: string | Record | unknown[], payload?: unknown): void; + // (undocumented) + state: 'initializing' | 'reconnecting' | 'connected'; + // (undocumented) + useChannel(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection; +} + // @public (undocumented) type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json']; @@ -2431,6 +2499,9 @@ type IWebhooksShowRequest = operations['i___webhooks___show']['requestBody']['co // @public (undocumented) type IWebhooksShowResponse = operations['i___webhooks___show']['responses']['200']['content']['application/json']; +// @public (undocumented) +type IWebhooksTestRequest = operations['i___webhooks___test']['requestBody']['content']['application/json']; + // @public (undocumented) type IWebhooksUpdateRequest = operations['i___webhooks___update']['requestBody']['content']['application/json']; @@ -2857,6 +2928,9 @@ type NotificationsCreateRequest = operations['notifications___create']['requestB // @public (undocumented) export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned"]; +// @public (undocumented) +export function nyaize(text: string): string; + // @public (undocumented) type Page = components['schemas']['Page']; @@ -3116,6 +3190,19 @@ type SigninResponse = { i: string; }; +// @public (undocumented) +type SigninWithPasskeyRequest = { + credential?: object; + context?: string; +}; + +// @public (undocumented) +type SigninWithPasskeyResponse = { + option?: object; + context?: string; + signinResponse?: SigninResponse; +}; + // @public (undocumented) type SignupPendingRequest = { code: string; @@ -3147,10 +3234,8 @@ type SignupResponse = MeDetailed & { // @public (undocumented) type StatsResponse = operations['stats']['responses']['200']['content']['application/json']; -// Warning: (ae-forgotten-export) The symbol "StreamEvents" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export class Stream extends EventEmitter { +export class Stream extends EventEmitter implements IStream { constructor(origin: string, user: { token: string; } | null, options?: { @@ -3158,20 +3243,14 @@ export class Stream extends EventEmitter { }); // (undocumented) close(): void; - // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts - // // (undocumented) disconnectToChannel(connection: NonSharedConnection): void; // (undocumented) heartbeat(): void; // (undocumented) ping(): void; - // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts - // // (undocumented) removeSharedConnection(connection: SharedConnection): void; - // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts - // // (undocumented) removeSharedConnectionPool(pool: Pool): void; // (undocumented) @@ -3186,6 +3265,14 @@ export class Stream extends EventEmitter { useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnection; } +// Warning: (ae-forgotten-export) The symbol "BroadcastEvents" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type StreamEvents = { + _connected_: void; + _disconnected_: void; +} & BroadcastEvents; + // Warning: (ae-forgotten-export) The symbol "SwitchCase" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "IsCaseMatched" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "GetCaseResult" needs to be exported by the entry point index.d.ts @@ -3479,7 +3566,7 @@ type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody'][' // // src/entities.ts:49:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:247:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:257:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts +// src/streaming.types.ts:258:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/cherrypick-js/generator/package.json b/packages/cherrypick-js/generator/package.json index 2aea57aac4..448956e42d 100644 --- a/packages/cherrypick-js/generator/package.json +++ b/packages/cherrypick-js/generator/package.json @@ -7,15 +7,15 @@ "generate": "tsx src/generator.ts && eslint ./built/**/*.ts --fix" }, "devDependencies": { - "@readme/openapi-parser": "2.5.0", + "@readme/openapi-parser": "2.6.0", "@types/node": "20.9.1", - "@typescript-eslint/eslint-plugin": "6.11.0", - "@typescript-eslint/parser": "6.11.0", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", "openapi-types": "12.1.3", "openapi-typescript": "6.7.3", - "ts-case-convert": "2.0.2", + "ts-case-convert": "2.0.7", "tsx": "4.4.0", - "typescript": "5.3.3" + "typescript": "5.6.2" }, "files": [ "built" diff --git a/packages/cherrypick-js/generator/src/generator.ts b/packages/cherrypick-js/generator/src/generator.ts index 6573efb838..8850c07461 100644 --- a/packages/cherrypick-js/generator/src/generator.ts +++ b/packages/cherrypick-js/generator/src/generator.ts @@ -96,15 +96,11 @@ async function generateEndpoints( endpoint.request = req; const reqType = new EndpointReqMediaType(path, req); - endpointReqMediaTypesSet.add(reqType.getMediaType()); - endpointReqMediaTypes.push(reqType); - } else { - endpointReqMediaTypesSet.add('application/json'); - endpointReqMediaTypes.push(new EndpointReqMediaType(path, undefined, 'application/json')); + if (reqType.getMediaType() !== 'application/json') { + endpointReqMediaTypesSet.add(reqType.getMediaType()); + endpointReqMediaTypes.push(reqType); + } } - } else { - endpointReqMediaTypesSet.add('application/json'); - endpointReqMediaTypes.push(new EndpointReqMediaType(path, undefined, 'application/json')); } if (operation.responses && isResponseObject(operation.responses['200']) && operation.responses['200'].content) { @@ -158,16 +154,19 @@ async function generateEndpoints( endpointOutputLine.push(''); function generateEndpointReqMediaTypesType() { - return `Record `'${t}'`).join(' | ')}>`; + return `{ [K in keyof Endpoints]?: ${[...endpointReqMediaTypesSet].map((t) => `'${t}'`).join(' | ')}; }`; } - endpointOutputLine.push(`export const endpointReqTypes: ${generateEndpointReqMediaTypesType()} = {`); + endpointOutputLine.push(`/** + * NOTE: The content-type for all endpoints not listed here is application/json. + */`); + endpointOutputLine.push('export const endpointReqTypes = {'); endpointOutputLine.push( ...endpointReqMediaTypes.map(it => '\t' + it.toLine()), ); - endpointOutputLine.push('};'); + endpointOutputLine.push(`} as const satisfies ${generateEndpointReqMediaTypesType()};`); endpointOutputLine.push(''); await writeFile(endpointOutputPath, endpointOutputLine.join('\n')); diff --git a/packages/cherrypick-js/jest.config.cjs b/packages/cherrypick-js/jest.config.cjs index 1230a4b5e2..267e0810fe 100644 --- a/packages/cherrypick-js/jest.config.cjs +++ b/packages/cherrypick-js/jest.config.cjs @@ -23,7 +23,7 @@ module.exports = { // collectCoverageFrom: undefined, // The directory where Jest should output its coverage files - coverageDirectory: "coverage", + coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection // coveragePathIgnorePatterns: [ @@ -31,7 +31,7 @@ module.exports = { // ], // Indicates which provider should be used to instrument code for coverage - coverageProvider: "v8", + coverageProvider: 'v8', // A list of reporter names that Jest uses when writing coverage reports // coverageReporters: [ @@ -128,7 +128,7 @@ module.exports = { // A list of paths to directories that Jest should use to search for files in roots: [ - "" + '', ], // Allows you to use a custom runner instead of Jest's default test runner @@ -147,7 +147,7 @@ module.exports = { // snapshotSerializers: [], // The test environment that will be used for testing - testEnvironment: "node", + testEnvironment: 'node', // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, @@ -157,9 +157,9 @@ module.exports = { // The glob patterns Jest uses to detect test files testMatch: [ - "**/__tests__/**/*.[jt]s?(x)", - "**/?(*.)+(spec|test).[tj]s?(x)", - "/test/**/*" + '**/__tests__/**/*.[jt]s?(x)', + '**/?(*.)+(spec|test).[tj]s?(x)', + '/test/**/*', ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped @@ -184,7 +184,7 @@ module.exports = { // A map from regular expressions to paths to transformers transform: { - "^.+\\.(t|j)sx?$": ["@swc/jest"], + '^.+\\.(t|j)sx?$': ['@swc/jest'], }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/packages/cherrypick-js/package.json b/packages/cherrypick-js/package.json index 93c3caa39a..91f83894af 100644 --- a/packages/cherrypick-js/package.json +++ b/packages/cherrypick-js/package.json @@ -1,8 +1,8 @@ { "type": "module", "name": "cherrypick-js", - "version": "4.11.1", - "basedMisskeyVersion": "2024.8.0", + "version": "4.12.0", + "basedMisskeyVersion": "2024.9.0", "description": "CherryPick SDK for JavaScript", "license": "MIT", "main": "./built/index.js", @@ -26,6 +26,9 @@ "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", "typecheck": "tsc --noEmit", "lint": "pnpm typecheck && pnpm eslint", + "biome-lint": "pnpm typecheck && pnpm biome lint", + "format": "pnpm biome format", + "format:write": "pnpm biome format --write", "jest": "jest --coverage --detectOpenHandles", "test": "pnpm jest && pnpm tsd", "update-autogen-code": "pnpm --filter cherrypick-js-type-generator generate && ncp generator/built/autogen src/autogen" @@ -36,9 +39,10 @@ "directory": "packages/cherrypick-js" }, "devDependencies": { - "@microsoft/api-extractor": "7.47.4", + "@biomejs/biome": "1.9.3", + "@microsoft/api-extractor": "7.47.9", "@swc/jest": "0.2.36", - "@types/jest": "29.5.12", + "@types/jest": "29.5.13", "@types/node": "20.14.12", "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.0", @@ -47,11 +51,11 @@ "jest-websocket-mock": "2.5.0", "mock-socket": "9.3.1", "ncp": "2.0.0", - "nodemon": "3.1.4", - "execa": "9.3.0", - "tsd": "0.31.1", - "typescript": "5.5.4", - "esbuild": "0.23.0", + "nodemon": "3.1.7", + "execa": "9.4.0", + "tsd": "0.31.2", + "typescript": "5.6.2", + "esbuild": "0.23.1", "glob": "11.0.0" }, "files": [ diff --git a/packages/cherrypick-js/src/api.ts b/packages/cherrypick-js/src/api.ts index ea1df57f3d..ed1282957f 100644 --- a/packages/cherrypick-js/src/api.ts +++ b/packages/cherrypick-js/src/api.ts @@ -56,6 +56,10 @@ export class APIClient { return obj !== null && typeof obj === 'object' && !Array.isArray(obj); } + private assertSpecialEpReqType(ep: keyof Endpoints): ep is keyof typeof endpointReqTypes { + return ep in endpointReqTypes; + } + public request( endpoint: E, params: P = {} as P, @@ -63,9 +67,12 @@ export class APIClient { ): Promise> { return new Promise((resolve, reject) => { let mediaType = 'application/json'; - if (endpoint in endpointReqTypes) { + // (autogenがバグったときのため、念の為nullチェックも行う) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.assertSpecialEpReqType(endpoint) && endpointReqTypes[endpoint] != null) { mediaType = endpointReqTypes[endpoint]; } + let payload: FormData | string = '{}'; if (mediaType === 'application/json') { @@ -100,7 +107,7 @@ export class APIClient { method: 'POST', body: payload, headers: { - 'Content-Type': endpointReqTypes[endpoint], + 'Content-Type': mediaType, }, credentials: 'omit', cache: 'no-cache', diff --git a/packages/cherrypick-js/src/api.types.ts b/packages/cherrypick-js/src/api.types.ts index 5ee4194db2..4c3f2e1578 100644 --- a/packages/cherrypick-js/src/api.types.ts +++ b/packages/cherrypick-js/src/api.types.ts @@ -5,6 +5,8 @@ import { PartialRolePolicyOverride, SigninRequest, SigninResponse, + SigninWithPasskeyRequest, + SigninWithPasskeyResponse, SignupPendingRequest, SignupPendingResponse, SignupRequest, @@ -82,6 +84,10 @@ export type Endpoints = Overwrite< req: SigninRequest; res: SigninResponse; }, + 'signin-with-passkey': { + req: SigninWithPasskeyRequest; + res: SigninWithPasskeyResponse; + } 'admin/roles/create': { req: Overwrite; res: AdminRolesCreateResponse; diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index 58a3578d73..6bb89d432f 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -1041,6 +1041,18 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:system-webhook* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * @@ -2922,6 +2934,18 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index f3bfdecc15..18a2ec0f72 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -127,6 +127,7 @@ import type { AdminSystemWebhookShowResponse, AdminSystemWebhookUpdateRequest, AdminSystemWebhookUpdateResponse, + AdminSystemWebhookTestRequest, AnnouncementsRequest, AnnouncementsResponse, AnnouncementsShowRequest, @@ -388,6 +389,7 @@ import type { IWebhooksShowResponse, IWebhooksUpdateRequest, IWebhooksDeleteRequest, + IWebhooksTestRequest, InviteCreateResponse, InviteDeleteRequest, InviteListRequest, @@ -712,6 +714,7 @@ export type Endpoints = { 'admin/system-webhook/list': { req: AdminSystemWebhookListRequest; res: AdminSystemWebhookListResponse }; 'admin/system-webhook/show': { req: AdminSystemWebhookShowRequest; res: AdminSystemWebhookShowResponse }; 'admin/system-webhook/update': { req: AdminSystemWebhookUpdateRequest; res: AdminSystemWebhookUpdateResponse }; + 'admin/system-webhook/test': { req: AdminSystemWebhookTestRequest; res: EmptyResponse }; 'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse }; 'announcements/show': { req: AnnouncementsShowRequest; res: AnnouncementsShowResponse }; 'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse }; @@ -880,6 +883,7 @@ export type Endpoints = { 'i/webhooks/show': { req: IWebhooksShowRequest; res: IWebhooksShowResponse }; 'i/webhooks/update': { req: IWebhooksUpdateRequest; res: EmptyResponse }; 'i/webhooks/delete': { req: IWebhooksDeleteRequest; res: EmptyResponse }; + 'i/webhooks/test': { req: IWebhooksTestRequest; res: EmptyResponse }; 'invite/create': { req: EmptyRequest; res: InviteCreateResponse }; 'invite/delete': { req: InviteDeleteRequest; res: EmptyResponse }; 'invite/list': { req: InviteListRequest; res: InviteListResponse }; @@ -1031,415 +1035,9 @@ export type Endpoints = { 'reversi/verify': { req: ReversiVerifyRequest; res: ReversiVerifyResponse }; } -export const endpointReqTypes: Record = { - 'admin/meta': 'application/json', - 'admin/abuse-report-resolver/create': 'application/json', - 'admin/abuse-report-resolver/list': 'application/json', - 'admin/abuse-report-resolver/delete': 'application/json', - 'admin/abuse-report-resolver/update': 'application/json', - 'admin/abuse-user-reports': 'application/json', - 'admin/abuse-report/notification-recipient/list': 'application/json', - 'admin/abuse-report/notification-recipient/show': 'application/json', - 'admin/abuse-report/notification-recipient/create': 'application/json', - 'admin/abuse-report/notification-recipient/update': 'application/json', - 'admin/abuse-report/notification-recipient/delete': 'application/json', - 'admin/accounts/create': 'application/json', - 'admin/accounts/delete': 'application/json', - 'admin/accounts/find-by-email': 'application/json', - 'admin/ad/create': 'application/json', - 'admin/ad/delete': 'application/json', - 'admin/ad/list': 'application/json', - 'admin/ad/update': 'application/json', - 'admin/announcements/create': 'application/json', - 'admin/announcements/delete': 'application/json', - 'admin/announcements/list': 'application/json', - 'admin/announcements/update': 'application/json', - 'admin/avatar-decorations/create': 'application/json', - 'admin/avatar-decorations/delete': 'application/json', - 'admin/avatar-decorations/list': 'application/json', - 'admin/avatar-decorations/update': 'application/json', - 'admin/delete-all-files-of-a-user': 'application/json', - 'admin/unset-user-avatar': 'application/json', - 'admin/unset-user-banner': 'application/json', - 'admin/drive/clean-remote-files': 'application/json', - 'admin/drive/cleanup': 'application/json', - 'admin/drive/files': 'application/json', - 'admin/drive/show-file': 'application/json', - 'admin/emoji/add-aliases-bulk': 'application/json', - 'admin/emoji/add': 'application/json', - 'admin/emoji/adds': 'application/json', - 'admin/emoji/copy': 'application/json', - 'admin/emoji/delete-bulk': 'application/json', - 'admin/emoji/delete': 'application/json', - 'admin/emoji/import-zip': 'application/json', - 'admin/emoji/list-remote': 'application/json', - 'admin/emoji/list': 'application/json', - 'admin/emoji/remove-aliases-bulk': 'application/json', - 'admin/emoji/set-aliases-bulk': 'application/json', - 'admin/emoji/set-category-bulk': 'application/json', - 'admin/emoji/set-license-bulk': 'application/json', - 'admin/emoji/steal': 'application/json', - 'admin/emoji/update': 'application/json', - 'admin/federation/delete-all-files': 'application/json', - 'admin/federation/refresh-remote-instance-metadata': 'application/json', - 'admin/federation/remove-all-following': 'application/json', - 'admin/federation/update-instance': 'application/json', - 'admin/get-index-stats': 'application/json', - 'admin/get-table-stats': 'application/json', - 'admin/get-user-ips': 'application/json', - 'admin/invite/create': 'application/json', - 'admin/invite/list': 'application/json', - 'admin/invite/revoke': 'application/json', - 'admin/promo/create': 'application/json', - 'admin/queue/clear': 'application/json', - 'admin/queue/deliver-delayed': 'application/json', - 'admin/queue/inbox-delayed': 'application/json', - 'admin/queue/promote': 'application/json', - 'admin/queue/stats': 'application/json', - 'admin/relays/add': 'application/json', - 'admin/relays/list': 'application/json', - 'admin/relays/remove': 'application/json', - 'admin/reset-password': 'application/json', - 'admin/resolve-abuse-user-report': 'application/json', - 'admin/send-email': 'application/json', - 'admin/server-info': 'application/json', - 'admin/show-moderation-logs': 'application/json', - 'admin/show-user': 'application/json', - 'admin/show-users': 'application/json', - 'admin/suspend-user': 'application/json', - 'admin/unsuspend-user': 'application/json', - 'admin/update-meta': 'application/json', - 'admin/delete-account': 'application/json', - 'admin/update-user-note': 'application/json', - 'admin/roles/create': 'application/json', - 'admin/roles/delete': 'application/json', - 'admin/roles/list': 'application/json', - 'admin/roles/show': 'application/json', - 'admin/roles/update': 'application/json', - 'admin/roles/assign': 'application/json', - 'admin/roles/unassign': 'application/json', - 'admin/roles/update-default-policies': 'application/json', - 'admin/roles/users': 'application/json', - 'admin/system-webhook/create': 'application/json', - 'admin/system-webhook/delete': 'application/json', - 'admin/system-webhook/list': 'application/json', - 'admin/system-webhook/show': 'application/json', - 'admin/system-webhook/update': 'application/json', - 'announcements': 'application/json', - 'announcements/show': 'application/json', - 'antennas/create': 'application/json', - 'antennas/delete': 'application/json', - 'antennas/list': 'application/json', - 'antennas/notes': 'application/json', - 'antennas/show': 'application/json', - 'antennas/update': 'application/json', - 'ap/get': 'application/json', - 'ap/show': 'application/json', - 'app/create': 'application/json', - 'app/show': 'application/json', - 'auth/accept': 'application/json', - 'auth/session/generate': 'application/json', - 'auth/session/show': 'application/json', - 'auth/session/userkey': 'application/json', - 'blocking/create': 'application/json', - 'blocking/delete': 'application/json', - 'blocking/list': 'application/json', - 'channels/create': 'application/json', - 'channels/featured': 'application/json', - 'channels/follow': 'application/json', - 'channels/followed': 'application/json', - 'channels/owned': 'application/json', - 'channels/show': 'application/json', - 'channels/timeline': 'application/json', - 'channels/unfollow': 'application/json', - 'channels/update': 'application/json', - 'channels/favorite': 'application/json', - 'channels/unfavorite': 'application/json', - 'channels/my-favorites': 'application/json', - 'channels/search': 'application/json', - 'charts/active-users': 'application/json', - 'charts/ap-request': 'application/json', - 'charts/drive': 'application/json', - 'charts/federation': 'application/json', - 'charts/instance': 'application/json', - 'charts/notes': 'application/json', - 'charts/user/drive': 'application/json', - 'charts/user/following': 'application/json', - 'charts/user/notes': 'application/json', - 'charts/user/pv': 'application/json', - 'charts/user/reactions': 'application/json', - 'charts/users': 'application/json', - 'clips/add-note': 'application/json', - 'clips/remove-note': 'application/json', - 'clips/create': 'application/json', - 'clips/delete': 'application/json', - 'clips/list': 'application/json', - 'clips/notes': 'application/json', - 'clips/show': 'application/json', - 'clips/update': 'application/json', - 'clips/favorite': 'application/json', - 'clips/unfavorite': 'application/json', - 'clips/my-favorites': 'application/json', - 'drive': 'application/json', - 'drive/files': 'application/json', - 'drive/files/attached-notes': 'application/json', - 'drive/files/check-existence': 'application/json', +/** + * NOTE: The content-type for all endpoints not listed here is application/json. + */ +export const endpointReqTypes = { 'drive/files/create': 'multipart/form-data', - 'drive/files/delete': 'application/json', - 'drive/files/find-by-hash': 'application/json', - 'drive/files/find': 'application/json', - 'drive/files/show': 'application/json', - 'drive/files/update': 'application/json', - 'drive/files/upload-from-url': 'application/json', - 'drive/folders': 'application/json', - 'drive/folders/create': 'application/json', - 'drive/folders/delete': 'application/json', - 'drive/folders/find': 'application/json', - 'drive/folders/show': 'application/json', - 'drive/folders/update': 'application/json', - 'drive/stream': 'application/json', - 'email-address/available': 'application/json', - 'endpoint': 'application/json', - 'endpoints': 'application/json', - 'export-custom-emojis': 'application/json', - 'federation/followers': 'application/json', - 'federation/following': 'application/json', - 'federation/instances': 'application/json', - 'federation/show-instance': 'application/json', - 'federation/update-remote-user': 'application/json', - 'federation/users': 'application/json', - 'federation/stats': 'application/json', - 'following/create': 'application/json', - 'following/delete': 'application/json', - 'following/update': 'application/json', - 'following/update-all': 'application/json', - 'following/invalidate': 'application/json', - 'following/requests/accept': 'application/json', - 'following/requests/cancel': 'application/json', - 'following/requests/list': 'application/json', - 'following/requests/reject': 'application/json', - 'gallery/featured': 'application/json', - 'gallery/popular': 'application/json', - 'gallery/posts': 'application/json', - 'gallery/posts/create': 'application/json', - 'gallery/posts/delete': 'application/json', - 'gallery/posts/like': 'application/json', - 'gallery/posts/show': 'application/json', - 'gallery/posts/unlike': 'application/json', - 'gallery/posts/update': 'application/json', - 'get-online-users-count': 'application/json', - 'get-avatar-decorations': 'application/json', - 'hashtags/list': 'application/json', - 'hashtags/search': 'application/json', - 'hashtags/show': 'application/json', - 'hashtags/trend': 'application/json', - 'hashtags/users': 'application/json', - 'i': 'application/json', - 'i/2fa/done': 'application/json', - 'i/2fa/key-done': 'application/json', - 'i/2fa/password-less': 'application/json', - 'i/2fa/register-key': 'application/json', - 'i/2fa/register': 'application/json', - 'i/2fa/update-key': 'application/json', - 'i/2fa/remove-key': 'application/json', - 'i/2fa/unregister': 'application/json', - 'i/apps': 'application/json', - 'i/authorized-apps': 'application/json', - 'i/claim-achievement': 'application/json', - 'i/change-password': 'application/json', - 'i/delete-account': 'application/json', - 'i/export-blocking': 'application/json', - 'i/export-following': 'application/json', - 'i/export-mute': 'application/json', - 'i/export-notes': 'application/json', - 'i/export-clips': 'application/json', - 'i/export-favorites': 'application/json', - 'i/export-user-lists': 'application/json', - 'i/export-antennas': 'application/json', - 'i/favorites': 'application/json', - 'i/gallery/likes': 'application/json', - 'i/gallery/posts': 'application/json', - 'i/import-blocking': 'application/json', - 'i/import-following': 'application/json', - 'i/import-muting': 'application/json', - 'i/import-user-lists': 'application/json', - 'i/import-antennas': 'application/json', - 'i/notifications': 'application/json', - 'i/notifications-grouped': 'application/json', - 'i/page-likes': 'application/json', - 'i/pages': 'application/json', - 'i/pin': 'application/json', - 'i/read-all-messaging-messages': 'application/json', - 'i/read-all-unread-notes': 'application/json', - 'i/read-announcement': 'application/json', - 'i/regenerate-token': 'application/json', - 'i/registry/get-all': 'application/json', - 'i/registry/get-detail': 'application/json', - 'i/registry/get': 'application/json', - 'i/registry/keys-with-type': 'application/json', - 'i/registry/keys': 'application/json', - 'i/registry/remove': 'application/json', - 'i/registry/scopes-with-domain': 'application/json', - 'i/registry/set': 'application/json', - 'i/revoke-token': 'application/json', - 'i/signin-history': 'application/json', - 'i/unpin': 'application/json', - 'i/update-email': 'application/json', - 'i/update': 'application/json', - 'i/user-group-invites': 'application/json', - 'i/move': 'application/json', - 'i/webhooks/create': 'application/json', - 'i/webhooks/list': 'application/json', - 'i/webhooks/show': 'application/json', - 'i/webhooks/update': 'application/json', - 'i/webhooks/delete': 'application/json', - 'invite/create': 'application/json', - 'invite/delete': 'application/json', - 'invite/list': 'application/json', - 'invite/limit': 'application/json', - 'messaging/history': 'application/json', - 'messaging/messages': 'application/json', - 'messaging/messages/create': 'application/json', - 'messaging/messages/delete': 'application/json', - 'messaging/messages/read': 'application/json', - 'meta': 'application/json', - 'emojis': 'application/json', - 'emoji': 'application/json', - 'miauth/gen-token': 'application/json', - 'mute/create': 'application/json', - 'mute/delete': 'application/json', - 'mute/list': 'application/json', - 'renote-mute/create': 'application/json', - 'renote-mute/delete': 'application/json', - 'renote-mute/list': 'application/json', - 'my/apps': 'application/json', - 'notes': 'application/json', - 'notes/children': 'application/json', - 'notes/clips': 'application/json', - 'notes/conversation': 'application/json', - 'notes/create': 'application/json', - 'notes/delete': 'application/json', - 'notes/update': 'application/json', - 'notes/favorites/create': 'application/json', - 'notes/favorites/delete': 'application/json', - 'notes/featured': 'application/json', - 'notes/global-timeline': 'application/json', - 'notes/hybrid-timeline': 'application/json', - 'notes/local-timeline': 'application/json', - 'notes/mentions': 'application/json', - 'notes/polls/recommendation': 'application/json', - 'notes/polls/vote': 'application/json', - 'notes/events/search': 'application/json', - 'notes/reactions': 'application/json', - 'notes/reactions/create': 'application/json', - 'notes/reactions/delete': 'application/json', - 'notes/renotes': 'application/json', - 'notes/replies': 'application/json', - 'notes/search-by-tag': 'application/json', - 'notes/search': 'application/json', - 'notes/show': 'application/json', - 'notes/state': 'application/json', - 'notes/thread-muting/create': 'application/json', - 'notes/thread-muting/delete': 'application/json', - 'notes/timeline': 'application/json', - 'notes/translate': 'application/json', - 'notes/unrenote': 'application/json', - 'notes/user-list-timeline': 'application/json', - 'notifications/create': 'application/json', - 'notifications/flush': 'application/json', - 'notifications/mark-all-as-read': 'application/json', - 'notifications/test-notification': 'application/json', - 'page-push': 'application/json', - 'pages/create': 'application/json', - 'pages/delete': 'application/json', - 'pages/featured': 'application/json', - 'pages/like': 'application/json', - 'pages/show': 'application/json', - 'pages/unlike': 'application/json', - 'pages/update': 'application/json', - 'flash/create': 'application/json', - 'flash/delete': 'application/json', - 'flash/featured': 'application/json', - 'flash/gen-token': 'application/json', - 'flash/like': 'application/json', - 'flash/show': 'application/json', - 'flash/unlike': 'application/json', - 'flash/update': 'application/json', - 'flash/my': 'application/json', - 'flash/my-likes': 'application/json', - 'ping': 'application/json', - 'pinned-users': 'application/json', - 'promo/read': 'application/json', - 'roles/list': 'application/json', - 'roles/show': 'application/json', - 'roles/users': 'application/json', - 'roles/notes': 'application/json', - 'request-reset-password': 'application/json', - 'reset-db': 'application/json', - 'reset-password': 'application/json', - 'server-info': 'application/json', - 'stats': 'application/json', - 'sw/show-registration': 'application/json', - 'sw/update-registration': 'application/json', - 'sw/register': 'application/json', - 'sw/unregister': 'application/json', - 'test': 'application/json', - 'username/available': 'application/json', - 'users': 'application/json', - 'users/clips': 'application/json', - 'users/followers': 'application/json', - 'users/following': 'application/json', - 'users/gallery/posts': 'application/json', - 'users/get-frequently-replied-users': 'application/json', - 'users/featured-notes': 'application/json', - 'users/groups/create': 'application/json', - 'users/groups/delete': 'application/json', - 'users/groups/invitations/accept': 'application/json', - 'users/groups/invitations/reject': 'application/json', - 'users/groups/invite': 'application/json', - 'users/groups/joined': 'application/json', - 'users/groups/leave': 'application/json', - 'users/groups/owned': 'application/json', - 'users/groups/pull': 'application/json', - 'users/groups/show': 'application/json', - 'users/groups/transfer': 'application/json', - 'users/groups/update': 'application/json', - 'users/lists/create': 'application/json', - 'users/lists/delete': 'application/json', - 'users/lists/list': 'application/json', - 'users/lists/pull': 'application/json', - 'users/lists/push': 'application/json', - 'users/lists/show': 'application/json', - 'users/lists/favorite': 'application/json', - 'users/lists/unfavorite': 'application/json', - 'users/lists/update': 'application/json', - 'users/lists/create-from-public': 'application/json', - 'users/lists/update-membership': 'application/json', - 'users/lists/get-memberships': 'application/json', - 'users/notes': 'application/json', - 'users/pages': 'application/json', - 'users/flashs': 'application/json', - 'users/reactions': 'application/json', - 'users/recommendation': 'application/json', - 'users/relation': 'application/json', - 'users/report-abuse': 'application/json', - 'users/search-by-username-and-host': 'application/json', - 'users/search': 'application/json', - 'users/show': 'application/json', - 'users/stats': 'application/json', - 'users/achievements': 'application/json', - 'users/update-memo': 'application/json', - 'users/translate': 'application/json', - 'fetch-rss': 'application/json', - 'fetch-external-resources': 'application/json', - 'retention': 'application/json', - 'bubble-game/register': 'application/json', - 'bubble-game/ranking': 'application/json', - 'reversi/cancel-match': 'application/json', - 'reversi/games': 'application/json', - 'reversi/match': 'application/json', - 'reversi/invitations': 'application/json', - 'reversi/show-game': 'application/json', - 'reversi/surrender': 'application/json', - 'reversi/verify': 'application/json', -}; +} as const satisfies { [K in keyof Endpoints]?: 'multipart/form-data'; }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index 97d3f6e978..16f6073c0d 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -130,6 +130,7 @@ export type AdminSystemWebhookShowRequest = operations['admin___system-webhook__ export type AdminSystemWebhookShowResponse = operations['admin___system-webhook___show']['responses']['200']['content']['application/json']; export type AdminSystemWebhookUpdateRequest = operations['admin___system-webhook___update']['requestBody']['content']['application/json']; export type AdminSystemWebhookUpdateResponse = operations['admin___system-webhook___update']['responses']['200']['content']['application/json']; +export type AdminSystemWebhookTestRequest = operations['admin___system-webhook___test']['requestBody']['content']['application/json']; export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json']; export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json']; export type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json']; @@ -391,6 +392,7 @@ export type IWebhooksShowRequest = operations['i___webhooks___show']['requestBod export type IWebhooksShowResponse = operations['i___webhooks___show']['responses']['200']['content']['application/json']; export type IWebhooksUpdateRequest = operations['i___webhooks___update']['requestBody']['content']['application/json']; export type IWebhooksDeleteRequest = operations['i___webhooks___delete']['requestBody']['content']['application/json']; +export type IWebhooksTestRequest = operations['i___webhooks___test']['requestBody']['content']['application/json']; export type InviteCreateResponse = operations['invite___create']['responses']['200']['content']['application/json']; export type InviteDeleteRequest = operations['invite___delete']['requestBody']['content']['application/json']; export type InviteListRequest = operations['invite___list']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index 352b7823ee..3dd0e714c7 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -864,6 +864,16 @@ export type paths = { */ post: operations['admin___system-webhook___update']; }; + '/admin/system-webhook/test': { + /** + * admin/system-webhook/test + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:system-webhook* + */ + post: operations['admin___system-webhook___test']; + }; '/announcements': { /** * announcements @@ -2521,6 +2531,16 @@ export type paths = { */ post: operations['i___webhooks___delete']; }; + '/i/webhooks/test': { + /** + * i/webhooks/test + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['i___webhooks___test']; + }; '/invite/create': { /** * invite/create @@ -4055,6 +4075,7 @@ export type components = { /** @default false */ securityKeys: boolean; roles: components['schemas']['RoleLite'][]; + followedMessage?: string | null; memo: string | null; moderationNote?: string; isFollowing?: boolean; @@ -4074,6 +4095,7 @@ export type components = { avatarId: string | null; /** Format: id */ bannerId: string | null; + followedMessage: string | null; isModerator: boolean | null; isAdmin: boolean | null; injectFeaturedNote: boolean; @@ -4444,6 +4466,8 @@ export type components = { reactionAndUserPairCache?: string[]; clippedCount?: number; myReaction?: string | null; + /** Format: date-time */ + deleteAt?: string | null; }; NoteReaction: { /** @@ -4566,7 +4590,7 @@ export type components = { user: components['schemas']['UserLite']; /** Format: id */ userId: string; - } | { + } | ({ /** Format: id */ id: string; /** Format: date-time */ @@ -4576,7 +4600,8 @@ export type components = { user: components['schemas']['UserLite']; /** Format: id */ userId: string; - } | { + message: string | null; + }) | { /** Format: id */ id: string; /** Format: date-time */ @@ -4584,15 +4609,27 @@ export type components = { /** @enum {string} */ type: 'roleAssigned'; role: components['schemas']['Role']; - } | { + } | ({ /** Format: id */ id: string; /** Format: date-time */ createdAt: string; /** @enum {string} */ type: 'achievementEarned'; - achievement: string; - } | { + /** @enum {string} */ + achievement: 'notes1' | 'notes10' | 'notes100' | 'notes500' | 'notes1000' | 'notes5000' | 'notes10000' | 'notes20000' | 'notes30000' | 'notes40000' | 'notes50000' | 'notes60000' | 'notes70000' | 'notes80000' | 'notes90000' | 'notes100000' | 'login3' | 'login7' | 'login15' | 'login30' | 'login60' | 'login100' | 'login200' | 'login300' | 'login400' | 'login500' | 'login600' | 'login700' | 'login800' | 'login900' | 'login1000' | 'passedSinceAccountCreated1' | 'passedSinceAccountCreated2' | 'passedSinceAccountCreated3' | 'loggedInOnBirthday' | 'loggedInOnNewYearsDay' | 'noteClipped1' | 'noteFavorited1' | 'myNoteFavorited1' | 'profileFilled' | 'markedAsCat' | 'following1' | 'following10' | 'following50' | 'following100' | 'following300' | 'followers1' | 'followers10' | 'followers50' | 'followers100' | 'followers300' | 'followers500' | 'followers1000' | 'collectAchievements30' | 'viewAchievements3min' | 'iLoveCherryPick' | 'foundTreasure' | 'client30min' | 'client60min' | 'noteDeletedWithin1min' | 'postedAtLateNight' | 'postedAt0min0sec' | 'selfQuote' | 'htl20npm' | 'viewInstanceChart' | 'outputHelloWorldOnScratchpad' | 'open3windows' | 'driveFolderCircularReference' | 'reactWithoutRead' | 'clickedClickHere' | 'justPlainLucky' | 'setNameToSyuilo' | 'setNameToNoriDev' | 'cookieClicked' | 'brainDiver' | 'smashTestNotificationButton' | 'tutorialCompleted' | 'bubbleGameExplodingHead' | 'bubbleGameDoubleExplodingHead'; + }) | ({ + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'exportCompleted'; + /** @enum {string} */ + exportedEntity: 'antenna' | 'blocking' | 'clip' | 'customEmoji' | 'favorite' | 'following' | 'muting' | 'note' | 'userList'; + /** Format: id */ + fileId: string; + }) | ({ /** Format: id */ id: string; /** Format: date-time */ @@ -4600,9 +4637,9 @@ export type components = { /** @enum {string} */ type: 'app'; body: string; - header: string; - icon: string; - } | { + header: string | null; + icon: string | null; + }) | { /** Format: id */ id: string; /** Format: date-time */ @@ -5137,6 +5174,7 @@ export type components = { canManageAvatarDecorations: boolean; canSearchNotes: boolean; canUseTranslator: boolean; + canUseAutoTranslate: boolean; canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; @@ -5151,6 +5189,11 @@ export type components = { userEachUserListsLimit: number; rateLimitFactor: number; avatarDecorationLimit: number; + canImportAntennas: boolean; + canImportBlocking: boolean; + canImportFollowing: boolean; + canImportMuting: boolean; + canImportUserLists: boolean; canEditNote: boolean; }; ReversiGameLite: { @@ -5279,6 +5322,7 @@ export type components = { imageUrl: string; dayOfWeek: number; }[]; + trustedLinkUrlPatterns: string[]; /** @default 0 */ notesPerOneAd: number; enableEmail: boolean; @@ -5299,6 +5343,7 @@ export type components = { * @enum {string} */ noteSearchableScope: 'local' | 'global'; + maxFileSize: number; }; MetaDetailedOnly: { features?: { @@ -5468,6 +5513,7 @@ export type operations = { perRemoteUserUserTimelineCacheMax: number; perUserHomeTimelineCacheMax: number; perUserListTimelineCacheMax: number; + enableReactionsBuffering: boolean; notesPerOneAd: number; backgroundImageUrl: string | null; deeplAuthKey: string | null; @@ -5501,11 +5547,15 @@ export type operations = { urlPreviewRequireContentLength: boolean; urlPreviewUserAgent: string | null; urlPreviewSummaryProxyUrl: string | null; + federation: string; + federationHosts: string[]; doNotSendNotificationEmailsForAbuseReport: boolean; emailToReceiveAbuseReport: string | null; enableReceivePrerelease: boolean; skipVersion: boolean; skipCherryPickVersion?: string | null; + trustedLinkUrlPatterns: string[]; + customSplashText: string[]; }; }; }; @@ -8996,7 +9046,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': ((string | number)[])[]; + 'application/json': [string, number][]; }; }; /** @description Client error */ @@ -9042,7 +9092,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': ((string | number)[])[]; + 'application/json': [string, number][]; }; }; /** @description Client error */ @@ -9676,6 +9726,7 @@ export type operations = { 'application/json': { email: string | null; emailVerified: boolean; + followedMessage: string | null; autoAcceptFollowed: boolean; noCrawle: boolean; preventAiLearning: boolean; @@ -10181,6 +10232,7 @@ export type operations = { perRemoteUserUserTimelineCacheMax?: number; perUserHomeTimelineCacheMax?: number; perUserListTimelineCacheMax?: number; + enableReactionsBuffering?: boolean; notesPerOneAd?: number; silencedHosts?: string[] | null; mediaSilencedHosts?: string[] | null; @@ -10192,11 +10244,16 @@ export type operations = { urlPreviewRequireContentLength?: boolean; urlPreviewUserAgent?: string | null; urlPreviewSummaryProxyUrl?: string | null; + /** @enum {string} */ + federation?: 'all' | 'none' | 'specified'; + federationHosts?: string[]; doNotSendNotificationEmailsForAbuseReport?: boolean; emailToReceiveAbuseReport?: string | null; enableReceivePrerelease?: boolean; skipVersion?: boolean; skipCherryPickVersion?: string | null; + trustedLinkUrlPatterns?: string[] | null; + customSplashText?: string[] | null; }; }; }; @@ -11138,6 +11195,71 @@ export type operations = { }; }; }; + /** + * admin/system-webhook/test + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:system-webhook* + */ + 'admin___system-webhook___test': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + webhookId: string; + /** @enum {string} */ + type: 'abuseReport' | 'abuseReportResolved' | 'userCreated'; + override?: { + url?: string; + secret?: string; + }; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description To many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * announcements * @description No description provided. @@ -19253,8 +19375,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote')[]; }; }; }; @@ -19321,8 +19443,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote')[]; }; }; }; @@ -20419,6 +20541,7 @@ export type operations = { 'application/json': { name?: string | null; description?: string | null; + followedMessage?: string | null; location?: string | null; birthday?: string | null; /** @enum {string|null} */ @@ -21078,6 +21201,71 @@ export type operations = { }; }; }; + /** + * i/webhooks/test + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + i___webhooks___test: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + webhookId: string; + /** @enum {string} */ + type: 'mention' | 'unfollow' | 'follow' | 'followed' | 'note' | 'reply' | 'renote' | 'reaction'; + override?: { + url?: string; + secret?: string; + }; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description To many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * invite/create * @description No description provided. @@ -22476,6 +22664,10 @@ export type operations = { end?: number | null; metadata?: Record; }) | null; + scheduledDelete?: ({ + deleteAt?: number | null; + deleteAfter?: number | null; + }) | null; }; }; }; diff --git a/packages/cherrypick-js/src/entities.ts b/packages/cherrypick-js/src/entities.ts index 949c97acca..4f8a449177 100644 --- a/packages/cherrypick-js/src/entities.ts +++ b/packages/cherrypick-js/src/entities.ts @@ -271,6 +271,17 @@ export type SigninRequest = { token?: string; }; +export type SigninWithPasskeyRequest = { + credential?: object; + context?: string; +}; + +export type SigninWithPasskeyResponse = { + option?: object; + context?: string; + signinResponse?: SigninResponse; +}; + export type SigninResponse = { id: User['id'], i: string, diff --git a/packages/cherrypick-js/src/index.ts b/packages/cherrypick-js/src/index.ts index ace9738e6a..e4c9364aa1 100644 --- a/packages/cherrypick-js/src/index.ts +++ b/packages/cherrypick-js/src/index.ts @@ -1,15 +1,6 @@ -import { type Endpoints } from './api.types.js'; import Stream, { Connection } from './streaming.js'; -import { type Channels } from './streaming.types.js'; -import { type Acct } from './acct.js'; import * as consts from './consts.js'; -export type { - Endpoints, - Channels, - Acct, -}; - export { Stream, Connection as ChannelConnection, @@ -31,4 +22,21 @@ import * as api from './api.js'; import * as entities from './entities.js'; import * as acct from './acct.js'; import * as note from './note.js'; -export { api, entities, acct, note }; +import { nyaize } from './nyaize.js'; +export { api, entities, acct, note, nyaize }; + +//#region standalone types +import type { Endpoints } from './api.types.js'; +import type { StreamEvents, IStream, IChannelConnection } from './streaming.js'; +import type { Channels } from './streaming.types.js'; +import type { Acct } from './acct.js'; + +export type { + Endpoints, + Channels, + Acct, + StreamEvents, + IStream, + IChannelConnection, +}; +//#endregion diff --git a/packages/frontend/src/scripts/nyaize.ts b/packages/cherrypick-js/src/nyaize.ts similarity index 85% rename from packages/frontend/src/scripts/nyaize.ts rename to packages/cherrypick-js/src/nyaize.ts index 35b7454a64..88d7113b6d 100644 --- a/packages/frontend/src/scripts/nyaize.ts +++ b/packages/cherrypick-js/src/nyaize.ts @@ -21,9 +21,9 @@ export function nyaize(text: string): string { .replace(enRegex3, x => x === 'ONE' ? 'NYAN' : 'nyan') .replace(enRegex4, x => x === 'NON' ? 'NYAN' : 'nyan') // ko-KR - .replace(koRegex1, match => String.fromCharCode( - match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0), - )) + .replace(koRegex1, match => !isNaN(match.charCodeAt(0)) ? String.fromCharCode( + match.charCodeAt(0) + '냐'.charCodeAt(0) - '나'.charCodeAt(0), + ) : match) .replace(koRegex2, '다냥') .replace(koRegex3, '냥') // el-GR diff --git a/packages/cherrypick-js/src/streaming.ts b/packages/cherrypick-js/src/streaming.ts index a300b64500..5acc38bfc9 100644 --- a/packages/cherrypick-js/src/streaming.ts +++ b/packages/cherrypick-js/src/streaming.ts @@ -17,16 +17,32 @@ export function urlQuery(obj: Record> = T[keyof T]; -type StreamEvents = { +export type StreamEvents = { _connected_: void; _disconnected_: void; } & BroadcastEvents; +export interface IStream extends EventEmitter { + state: 'initializing' | 'reconnecting' | 'connected'; + + useChannel(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection; + removeSharedConnection(connection: SharedConnection): void; + removeSharedConnectionPool(pool: Pool): void; + disconnectToChannel(connection: NonSharedConnection): void; + send(typeOrPayload: string): void; + send(typeOrPayload: string, payload: unknown): void; + send(typeOrPayload: Record | unknown[]): void; + send(typeOrPayload: string | Record | unknown[], payload?: unknown): void; + ping(): void; + heartbeat(): void; + close(): void; +} + /** * CherryPick stream connection */ // eslint-disable-next-line import/no-default-export -export default class Stream extends EventEmitter { +export default class Stream extends EventEmitter implements IStream { private stream: _ReconnectingWebsocket.default; public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing'; private sharedConnectionPools: Pool[] = []; @@ -277,7 +293,18 @@ class Pool { } } -export abstract class Connection = AnyOf> extends EventEmitter { +export interface IChannelConnection = AnyOf> extends EventEmitter { + id: string; + name?: string; + inCount: number; + outCount: number; + channel: string; + + send(type: T, body: Channel['receives'][T]): void; + dispose(): void; +} + +export abstract class Connection = AnyOf> extends EventEmitter implements IChannelConnection { public channel: string; protected stream: Stream; public abstract id: string; diff --git a/packages/cherrypick-js/src/streaming.types.ts b/packages/cherrypick-js/src/streaming.types.ts index 9cfd800936..0f2f90007c 100644 --- a/packages/cherrypick-js/src/streaming.types.ts +++ b/packages/cherrypick-js/src/streaming.types.ts @@ -151,7 +151,7 @@ export type Channels = { }; hashtag: { params: { - q?: string; + q: string[][]; }; events: { note: (payload: Note) => void; @@ -246,6 +246,7 @@ export type Channels = { changeReadyStates: (payload: { user1: boolean; user2: boolean; }) => void; updateSettings: (payload: { userId: User['id']; key: K; value: ReversiGameDetailed[K]; }) => void; log: (payload: Record) => void; + reacted: (payload: { userId: User['id']; reaction: string; }) => void; }; receives: { putStone: { @@ -256,11 +257,12 @@ export type Channels = { cancel: null | Record; updateSettings: ReversiUpdateSettings; claimTimeIsUp: null | Record; + reaction: string; } } }; -export type NoteUpdatedEvent = { +export type NoteUpdatedEvent = { id: Note['id'] } & ({ type: 'reacted'; body: { reaction: string; @@ -290,7 +292,7 @@ export type NoteUpdatedEvent = { choice: number; userId: User['id']; }; -}; +}); export type BroadcastEvents = { noteUpdated: (payload: NoteUpdatedEvent) => void; diff --git a/packages/frontend-embed/.gitignore b/packages/frontend-embed/.gitignore new file mode 100644 index 0000000000..1aa0ac14e8 --- /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 0000000000..2f6fc9e729 --- /dev/null +++ b/packages/frontend-embed/@types/global.d.ts @@ -0,0 +1,24 @@ +/* + * 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 _BASEDMISSKEYVERSION_: 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 0000000000..6ac1037493 --- /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 0000000000..39332b0c1b Binary files /dev/null and b/packages/frontend-embed/assets/dummy.png differ diff --git a/packages/frontend-embed/biome.json b/packages/frontend-embed/biome.json new file mode 100644 index 0000000000..3867749276 --- /dev/null +++ b/packages/frontend-embed/biome.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js new file mode 100644 index 0000000000..917bd3a82a --- /dev/null +++ b/packages/frontend-embed/eslint.config.js @@ -0,0 +1,96 @@ +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, + + // CherryPick + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _BASEDMISSKEYVERSION_: 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 0000000000..86fcdf0d81 --- /dev/null +++ b/packages/frontend-embed/package.json @@ -0,0 +1,76 @@ +{ + "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", + "biome-lint": "pnpm typecheck && pnpm biome lint", + "format": "pnpm biome format", + "format:write": "pnpm biome format --write" + }, + "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", + "cfm-js": "0.24.0-cherrypick.8", + "cherrypick-js": "workspace:*", + "estree-walker": "3.0.3", + "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": { + "@biomejs/biome": "1.9.3", + "@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": "7.17.0", + "@typescript-eslint/parser": "7.17.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.30.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 0000000000..3421f43c3c --- /dev/null +++ b/packages/frontend-embed/src/boot.ts @@ -0,0 +1,140 @@ +/* + * 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 { url } from '@@/js/config.js'; +import { parseEmbedParams } from '@@/js/embed-page.js'; +import type { Theme } from '@/theme.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 { postMessageToParentWindow, setIframeId } from '@/post-message.js'; +import { serverContext } from '@/server-context.js'; + +console.log('CherryPick 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 CHERRYPICK_MOUNT_DIV_ID = 'cherrypick_app'; + + const currentRoot = document.getElementById(CHERRYPICK_MOUNT_DIV_ID); + + if (currentRoot) { + console.warn('multiple import detected'); + return currentRoot; + } + + const root = document.createElement('div'); + root.id = CHERRYPICK_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 0000000000..1c236b9a35 --- /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 0000000000..4c2d285a4f --- /dev/null +++ b/packages/frontend-embed/src/components/EmAcct.vue @@ -0,0 +1,24 @@ + + + + @{{ user.username }} + @{{ user.host || host }} + + + + diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue new file mode 100644 index 0000000000..cca7df29a4 --- /dev/null +++ b/packages/frontend-embed/src/components/EmAvatar.vue @@ -0,0 +1,264 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmCustomEmoji.vue b/packages/frontend-embed/src/components/EmCustomEmoji.vue new file mode 100644 index 0000000000..e4149cf363 --- /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 0000000000..a0320f7c36 --- /dev/null +++ b/packages/frontend-embed/src/components/EmEmoji.vue @@ -0,0 +1,31 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmError.vue b/packages/frontend-embed/src/components/EmError.vue new file mode 100644 index 0000000000..d376b29a7f --- /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 0000000000..254c2b1a0e --- /dev/null +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -0,0 +1,246 @@ + + + + + + + + + diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue new file mode 100644 index 0000000000..6f72908079 --- /dev/null +++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmLink.vue b/packages/frontend-embed/src/components/EmLink.vue new file mode 100644 index 0000000000..a4333feb83 --- /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 0000000000..13cb61062b --- /dev/null +++ b/packages/frontend-embed/src/components/EmLoading.vue @@ -0,0 +1,110 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue new file mode 100644 index 0000000000..ce574dc188 --- /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 0000000000..10b496d27b --- /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 0000000000..bad1fd9698 --- /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 0000000000..979371c5c2 --- /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 0000000000..a631783507 --- /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 0000000000..7544f66d19 --- /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 'cfm-js'; +import * as Misskey from 'cherrypick-js'; +import { host } from '@@/js/config.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'; + +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 0000000000..e7508b9103 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -0,0 +1,637 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue new file mode 100644 index 0000000000..dd521e9b1e --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -0,0 +1,574 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue new file mode 100644 index 0000000000..e251c8366c --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteHeader.vue @@ -0,0 +1,163 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue new file mode 100644 index 0000000000..081b1dc477 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -0,0 +1,109 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue new file mode 100644 index 0000000000..5dc104fc74 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -0,0 +1,162 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue new file mode 100644 index 0000000000..3418d97f77 --- /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 0000000000..25e01777bb --- /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 0000000000..2c02ef1547 --- /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 0000000000..5c38ecb0ed --- /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 0000000000..c8601806da --- /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 0000000000..87f66eaa63 --- /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 0000000000..79e6332f66 --- /dev/null +++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue @@ -0,0 +1,200 @@ + + + + + + + diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue new file mode 100644 index 0000000000..f12eaa5ea2 --- /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 0000000000..6c30b1102d --- /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 0000000000..6cb571ce4b --- /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 0000000000..9610ec1a15 --- /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 0000000000..b621110ec9 --- /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 0000000000..62bcb1db61 --- /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 'cherrypick-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 0000000000..37ef8e0574 --- /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 * as Misskey from 'cherrypick-js'; +import { MediaProxy } from '@@/js/media-proxy.js'; +import type { InjectionKey } from 'vue'; +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 0000000000..2da61b8dac --- /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 { locale } from '@@/js/config.js'; +import type { Locale } from '../../../locales/index.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 0000000000..bf9f107a15 --- /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 0000000000..e2f80a3061 --- /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 'cherrypick-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 0000000000..203526ee41 --- /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 0000000000..30b41357ae --- /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 0000000000..f2104912de --- /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 0000000000..fbd8ed30a4 --- /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 0000000000..5b11e91c68 --- /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 0000000000..fd8eb8a5d2 --- /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 0000000000..c39f5d1910 --- /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 'cherrypick-js'; + +const providedContextEl = document.getElementById('cherrypick_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 0000000000..391fb60380 --- /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 'cherrypick-js'; +import { misskeyApi } from '@/misskey-api.js'; + +const providedMetaEl = document.getElementById('cherrypick_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 0000000000..4c84da141e --- /dev/null +++ b/packages/frontend-embed/src/style.scss @@ -0,0 +1,466 @@ +@charset "utf-8"; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@import url("https://cdn.jsdelivr.net/npm/jetbrains-mono@1.0.6/css/jetbrains-mono.min.css"); +@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"); +@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp.min.css"); + +$default-font: "Pretendard JP", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Hiragino Sans", "Apple SD Gothic Neo", Meiryo, "Noto Sans JP", "Noto Sans KR", "Malgun Gothic", Osaka, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; +$monospace-font: "Pretendard JP", Pretendard, "JetBrains Mono", "Fira code", "Fira Mono", Consolas, Menlo, Courier, monospace !important; + +: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: $default-font; + 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; +} + +#cherrypick_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: $monospace-font; +} + +// CFM ----------------------------- + +._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%); } +} + +@keyframes mfm-fade { + 0% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts new file mode 100644 index 0000000000..23e70cd0d3 --- /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 0000000000..c0cd10bcae --- /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 0000000000..26fd5a0d76 --- /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 'cherrypick-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 0000000000..22de6cd3a8 --- /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 0000000000..b203ebe666 --- /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 0000000000..8ee8930465 --- /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 0000000000..3701343623 --- /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 0000000000..bf2f478887 --- /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 0000000000..248babaae1 --- /dev/null +++ b/packages/frontend-embed/vite.config.ts @@ -0,0 +1,166 @@ +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; + }, + }, + // tinyld가 특수 UTF-8 문자를 사용하므로 Vite 빌드 과정에서 제외하고 CDN을 통해 로드함. + // https://github.com/komodojp/tinyld/issues/29#issuecomment-2165835459 + { + name: 'tinyld', + match: /^tinyld$/, + path(): string { + return `https://cdn.jsdelivr.net/npm/tinyld@${packageInfo.dependencies.tinyld}/dist/tinyld.normal.node.mjs` + }, + }, +]; + +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), + _BASEDMISSKEYVERSION_: JSON.stringify(meta.basedMisskeyVersion), + _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('CherryPick:'), + __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' ? `${meta.version}.[hash:8].js` : `${meta.version}.[name]-[hash:8].js`, + assetFileNames: process.env.NODE_ENV === 'production' ? `${meta.version}.[hash:8][extname]` : `${meta.version}.[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: [/cherrypick-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 0000000000..87b67c2142 --- /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 0000000000..eba994772d --- /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 0000000000..5f6be09d7c --- /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 0000000000..232c12f3bf --- /dev/null +++ b/packages/frontend-shared/@types/global.d.ts @@ -0,0 +1,26 @@ +/* + * 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 _BASEDMISSKEYVERSION_: 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/biome.json b/packages/frontend-shared/biome.json new file mode 100644 index 0000000000..3867749276 --- /dev/null +++ b/packages/frontend-shared/biome.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js new file mode 100644 index 0000000000..17b6da8d30 --- /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 0000000000..cd4641a270 --- /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/array.ts b/packages/frontend-shared/js/array.ts similarity index 100% rename from packages/frontend/src/scripts/array.ts rename to packages/frontend-shared/js/array.ts diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend-shared/js/collapsed.ts similarity index 88% rename from packages/frontend/src/scripts/collapsed.ts rename to packages/frontend-shared/js/collapsed.ts index 5fe0db6913..8c0ba4a308 100644 --- a/packages/frontend/src/scripts/collapsed.ts +++ b/packages/frontend-shared/js/collapsed.ts @@ -7,11 +7,11 @@ import * as Misskey from 'cherrypick-js'; export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean { return note.cw == null && ( - note.text != null && ( + (note.text != null && ( (note.text.split('\n').length > 9) || (note.text.length > 500) || (urls.length >= 4) - ) || note.files.length >= 5 + )) || (note.files != null && note.files.length >= 5) ); } diff --git a/packages/frontend/src/config.ts b/packages/frontend-shared/js/config.ts similarity index 53% rename from packages/frontend/src/config.ts rename to packages/frontend-shared/js/config.ts index 77d3f92f81..57095e9ff2 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,16 +14,16 @@ 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 basedMisskeyVersion = _BASEDMISSKEYVERSION_; -export const instanceName = siteName === 'CherryPick' || siteName == null ? host : siteName; -export const ui = miLocalStorage.getItem('ui'); -export const debug = miLocalStorage.getItem('debug') === 'true'; +export const instanceName = (siteName === 'CherryPick' || 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 95% rename from packages/frontend/src/const.ts rename to packages/frontend-shared/js/const.ts index 905e10007b..5b56d97870 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -68,6 +68,8 @@ export const notificationTypes = [ 'groupInvited', 'roleAssigned', 'achievementEarned', + 'exportCompleted', + 'test', 'app', ] as const; export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as const; @@ -86,6 +88,7 @@ export const ROLE_POLICIES = [ 'canManageAvatarDecorations', 'canSearchNotes', 'canUseTranslator', + 'canUseAutoTranslate', 'canHideAds', 'driveCapacityMb', 'alwaysMarkNsfw', @@ -100,6 +103,11 @@ export const ROLE_POLICIES = [ 'userEachUserListsLimit', 'rateLimitFactor', 'avatarDecorationLimit', + 'canImportAntennas', + 'canImportBlocking', + 'canImportFollowing', + 'canImportMuting', + 'canImportUserLists', ] as const; // なんか動かない diff --git a/packages/frontend-shared/js/embed-page.ts b/packages/frontend-shared/js/embed-page.ts new file mode 100644 index 0000000000..d5555a98c3 --- /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 a01540a3e4..5fbbc4ea84 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 6565feba97..bde30a864f 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 c2f44a33cc..18232691fa 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 0000000000..33b65b6e9b --- /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 0000000000..946f86400e --- /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 0000000000..3e7bcbe079 --- /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 'cherrypick-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 f0274034b5..4f2e9105c3 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 5a8265af9e..eb830b1eea 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 a8f4d5e03a..b1197e68da 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 6b3fcd9383..5d393ed1ed 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 0000000000..1ac222924c --- /dev/null +++ b/packages/frontend-shared/package.json @@ -0,0 +1,43 @@ +{ + "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", + "biome-lint": "pnpm typecheck && pnpm biome lint", + "format": "pnpm biome format", + "format:write": "pnpm biome format --write" + }, + "devDependencies": { + "@biomejs/biome": "1.9.3", + "@types/node": "20.14.12", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.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": { + "cherrypick-js": "workspace:*", + "vue": "3.4.37" + } +} diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 similarity index 88% rename from packages/frontend/src/themes/_dark.json5 rename to packages/frontend-shared/themes/_dark.json5 index 4055daf207..1982feb23d 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', @@ -21,6 +22,7 @@ fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':lighten<3<@fg', fgOnAccent: '#fff', + fgOnWhite: '#333', divider: 'rgba(255, 255, 255, 0.1)', indicator: '@accent', panel: ':lighten<3<@bg', @@ -55,11 +57,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', @@ -82,26 +86,16 @@ htmlThemeColor: '@bg', chatReadBg: ':lighten<1<@bg', cherry: 'rgb(255, 207, 230)', - cherryX8: ':lighten<5<@cherry', pick: 'rgb(185, 216, 255)', pickLighten: ':lighten<10<@pick', - pickX8: ':lighten<5<@pick', - 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 88% rename from packages/frontend/src/themes/_light.json5 rename to packages/frontend-shared/themes/_light.json5 index ed7bf319d2..bb63736484 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', @@ -21,6 +22,7 @@ fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':darken<3<@fg', fgOnAccent: '#fff', + fgOnWhite: '#333', divider: 'rgba(0, 0, 0, 0.1)', indicator: '@accent', panel: ':lighten<3<@bg', @@ -55,11 +57,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', @@ -82,26 +86,16 @@ htmlThemeColor: '@bg', chatReadBg: ':lighten<1<@bg', cherry: 'rgb(255, 188, 220)', - cherryX8: ':lighten<5<@cherry', pick: 'rgb(177, 211, 255)', pickLighten: ':lighten<10<@pick', - pickX8: ':lighten<5<@pick', - 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 7bb1c708c4..0c86cf5d66 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-birdsite.json5 b/packages/frontend-shared/themes/d-birdsite.json5 similarity index 100% rename from packages/frontend/src/themes/d-birdsite.json5 rename to packages/frontend-shared/themes/d-birdsite.json5 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-byeolvit-noctiluca.json5 b/packages/frontend-shared/themes/d-byeolvit-noctiluca.json5 similarity index 92% rename from packages/frontend/src/themes/d-byeolvit-noctiluca.json5 rename to packages/frontend-shared/themes/d-byeolvit-noctiluca.json5 index e3d0e69d47..56708a3e16 100644 --- a/packages/frontend/src/themes/d-byeolvit-noctiluca.json5 +++ b/packages/frontend-shared/themes/d-byeolvit-noctiluca.json5 @@ -4,24 +4,16 @@ desc: '푸른 별빛이 자아내는 잔향, Byeolvit Noctiluca(별빛 녹틸루카)는 Byeolvit의 기본 다크 모드 테마입니다.', name: 'Byeolvit Noctiluca Rev.1', 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: '#121417', fg: '#E4ECEA', - 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', link: ':lighten<15<@accent', warn: '@infoWarnFg', badge: '@infoFg', 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-cherrypick.json5 b/packages/frontend-shared/themes/d-cherrypick.json5 similarity index 100% rename from packages/frontend/src/themes/d-cherrypick.json5 rename to packages/frontend-shared/themes/d-cherrypick.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/src/themes/d-mirerado.json5 b/packages/frontend-shared/themes/d-mirerado.json5 similarity index 100% rename from packages/frontend/src/themes/d-mirerado.json5 rename to packages/frontend-shared/themes/d-mirerado.json5 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-qdon.json5 b/packages/frontend-shared/themes/d-qdon.json5 similarity index 100% rename from packages/frontend/src/themes/d-qdon.json5 rename to packages/frontend-shared/themes/d-qdon.json5 diff --git a/packages/frontend/src/themes/d-rosepine.json5 b/packages/frontend-shared/themes/d-rosepine.json5 similarity index 91% rename from packages/frontend/src/themes/d-rosepine.json5 rename to packages/frontend-shared/themes/d-rosepine.json5 index 55d9a2df47..528f6719e3 100644 --- a/packages/frontend/src/themes/d-rosepine.json5 +++ b/packages/frontend-shared/themes/d-rosepine.json5 @@ -4,24 +4,16 @@ desc: 'Soho vibes for Misskey', name: 'Rosé Pine', 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: '#191724', fg: '#e0def4', - 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', link: '#9ccfd8', warn: '#f6c177', badge: '#ebbcba', diff --git a/packages/frontend/src/themes/d-rosepinemoon.json5 b/packages/frontend-shared/themes/d-rosepinemoon.json5 similarity index 91% rename from packages/frontend/src/themes/d-rosepinemoon.json5 rename to packages/frontend-shared/themes/d-rosepinemoon.json5 index 5f24af2355..6b44d4ed80 100644 --- a/packages/frontend/src/themes/d-rosepinemoon.json5 +++ b/packages/frontend-shared/themes/d-rosepinemoon.json5 @@ -4,24 +4,16 @@ desc: 'Soho vibes for Misskey, moon edition', name: 'Rosé Pine Moon', 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: '#232136', fg: '#e0def4', - 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', link: '#3e8fb0', warn: '#f6c177', badge: '#ea9a97', diff --git a/packages/frontend/src/themes/d-scone-color.json5 b/packages/frontend-shared/themes/d-scone-color.json5 similarity index 92% rename from packages/frontend/src/themes/d-scone-color.json5 rename to packages/frontend-shared/themes/d-scone-color.json5 index 81aba1f989..64fd6d864a 100644 --- a/packages/frontend/src/themes/d-scone-color.json5 +++ b/packages/frontend-shared/themes/d-scone-color.json5 @@ -4,24 +4,16 @@ desc: '버터스콘이 까맣게 탔습니다. 밤에 먹으면 탄 줄도 모르니 괜찮습니다.', name: 'scone.color 0.0.1', 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: '#211b19', fg: '#fffbe7', - 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', link: '#eedb97', warn: '#ecb637', badge: '#fadda1', diff --git a/packages/frontend/src/themes/d-stella-r2.json5 b/packages/frontend-shared/themes/d-stella-r2.json5 similarity index 100% rename from packages/frontend/src/themes/d-stella-r2.json5 rename to packages/frontend-shared/themes/d-stella-r2.json5 diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 similarity index 87% rename from packages/frontend/src/themes/d-u0.json5 rename to packages/frontend-shared/themes/d-u0.json5 index c66c536777..8a869c26b1 100644 --- a/packages/frontend/src/themes/d-u0.json5 +++ b/packages/frontend-shared/themes/d-u0.json5 @@ -3,24 +3,16 @@ 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', 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', link: '@accent', warn: '#ecb637', badge: '#afd2ff', @@ -41,7 +33,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 +55,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-birdsite.json5 b/packages/frontend-shared/themes/l-birdsite.json5 similarity index 100% rename from packages/frontend/src/themes/l-birdsite.json5 rename to packages/frontend-shared/themes/l-birdsite.json5 diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5 similarity index 90% rename from packages/frontend/src/themes/l-botanical.json5 rename to packages/frontend-shared/themes/l-botanical.json5 index 17e9ca246f..56f9cc227d 100644 --- a/packages/frontend/src/themes/l-botanical.json5 +++ b/packages/frontend-shared/themes/l-botanical.json5 @@ -13,11 +13,11 @@ fgHighlighted: '#6bc9a0', fgOnWhite: '@accent', divider: '#cfcfcf', - panel: '@X14', + panel: '#ebe7e5', panelHeaderBg: '@panel', panelHeaderDivider: '@divider', header: ':alpha<0.7<@panel', - navBg: '@X14', + navBg: '#ebe7e5', renote: '#229e92', mention: '#da6d35', mentionMe: '#d44c4c', @@ -25,6 +25,5 @@ link: '@accent', buttonGradateB: ':hue<-70<@accent', success: '#86b300', - X14: '#ebe7e5' }, } diff --git a/packages/frontend/src/themes/l-byeolvit-polaris.json5 b/packages/frontend-shared/themes/l-byeolvit-polaris.json5 similarity index 92% rename from packages/frontend/src/themes/l-byeolvit-polaris.json5 rename to packages/frontend-shared/themes/l-byeolvit-polaris.json5 index 6839649a80..46fb33b2cc 100644 --- a/packages/frontend/src/themes/l-byeolvit-polaris.json5 +++ b/packages/frontend-shared/themes/l-byeolvit-polaris.json5 @@ -4,24 +4,16 @@ desc: '새하얀 별빛의 이정표, Byeolvit Polaris(별빛 폴라리스)는 Byeolvit의 기본 라이트 모드 테마입니다.', name: 'Byeolvit Polaris Rev.1', props: { - 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', bg: '#E9ECEC', fg: '#053328', - 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', link: ':lighten<2.5<@accent', warn: '@infoWarnFg', badge: '@infoFg', 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-cherrypick.json5 b/packages/frontend-shared/themes/l-cherrypick.json5 similarity index 100% rename from packages/frontend/src/themes/l-cherrypick.json5 rename to packages/frontend-shared/themes/l-cherrypick.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/src/themes/l-mirerado.json5 b/packages/frontend-shared/themes/l-mirerado.json5 similarity index 100% rename from packages/frontend/src/themes/l-mirerado.json5 rename to packages/frontend-shared/themes/l-mirerado.json5 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-rosepinedawn.json5 b/packages/frontend-shared/themes/l-rosepinedawn.json5 similarity index 91% rename from packages/frontend/src/themes/l-rosepinedawn.json5 rename to packages/frontend-shared/themes/l-rosepinedawn.json5 index 7a9493cc20..6f10b869c1 100644 --- a/packages/frontend/src/themes/l-rosepinedawn.json5 +++ b/packages/frontend-shared/themes/l-rosepinedawn.json5 @@ -65,22 +65,14 @@ codeNumber: '#0fbbbb', codeBoolean: '#62b70c', 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', }, author: '@thatonecalculator@stop.voring.me', } diff --git a/packages/frontend/src/themes/l-scone-color.json5 b/packages/frontend-shared/themes/l-scone-color.json5 similarity index 91% rename from packages/frontend/src/themes/l-scone-color.json5 rename to packages/frontend-shared/themes/l-scone-color.json5 index 862515edb3..82e5d323da 100644 --- a/packages/frontend/src/themes/l-scone-color.json5 +++ b/packages/frontend-shared/themes/l-scone-color.json5 @@ -3,24 +3,16 @@ base: 'light', name: 'buttersconedefault', props: { - 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', bg: '#FFFBE7', fg: '#736955', - 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', link: '#44a4c1', warn: '#ecb637', badge: '#31b1ce', diff --git a/packages/frontend/src/themes/l-stella-r2.json5 b/packages/frontend-shared/themes/l-stella-r2.json5 similarity index 100% rename from packages/frontend/src/themes/l-stella-r2.json5 rename to packages/frontend-shared/themes/l-stella-r2.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 91% rename from packages/frontend/src/themes/l-u0.json5 rename to packages/frontend-shared/themes/l-u0.json5 index 667b7c081e..40fc20a300 100644 --- a/packages/frontend/src/themes/l-u0.json5 +++ b/packages/frontend-shared/themes/l-u0.json5 @@ -3,24 +3,16 @@ 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', 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', link: '@accent', warn: '#ecb637', badge: '#afd2ff', 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 d3cdaf778f..507de10021 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 0000000000..09a8ff76aa --- /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/generate.tsx b/packages/frontend/.storybook/generate.tsx index e1eb73707c..2d9a3faa15 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -405,8 +405,9 @@ function toStories(component: string): Promise { 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'), glob('src/components/global/CP*.vue'), ]); const components = globs.flat(); diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts index e7ebbedd10..c6527776e3 100644 --- a/packages/frontend/.storybook/preload-theme.ts +++ b/packages/frontend/.storybook/preload-theme.ts @@ -46,7 +46,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 0a7281898d..70afc356c1 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/assets/tagcanvas.min.js b/packages/frontend/assets/tagcanvas.min.js index bcee46e682..02fc7476ee 100644 --- a/packages/frontend/assets/tagcanvas.min.js +++ b/packages/frontend/assets/tagcanvas.min.js @@ -10,7 +10,7 @@ * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. - * + * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see . */ diff --git a/packages/frontend/biome.json b/packages/frontend/biome.json new file mode 100644 index 0000000000..3867749276 --- /dev/null +++ b/packages/frontend/biome.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c4af3e0055..173abe2c93 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -14,39 +14,42 @@ "test-and-coverage": "vitest --run --coverage --globals", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", - "lint": "pnpm typecheck && pnpm eslint" + "lint": "pnpm typecheck && pnpm eslint", + "biome-lint": "pnpm typecheck && pnpm biome lint", + "format": "pnpm biome format", + "format:write": "pnpm biome format --write" }, "dependencies": { - "@discordapp/twemoji": "15.0.3", + "@dice-roller/rpg-dice-roller": "^5.5.0", + "@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.37", + "@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", "autosize": "6.0.1", "broadcast-channel": "7.0.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", - "chart.js": "4.4.3", + "cfm-js": "0.24.0-cherrypick.8", + "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", "cherrypick-js": "workspace:*", - "cherrypick-mfm-js": "0.24.0-cherrypick.4", - "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", @@ -56,94 +59,98 @@ "matter-js": "0.19.0", "misskey-bubble-game": "workspace:*", "misskey-reversi": "workspace:*", + "frontend-shared": "workspace:*", "photoswipe": "5.4.4", "prismjs": "1.29.0", "punycode": "2.3.1", - "rollup": "4.19.1", + "qrcode": "^1.5.4", + "qrcode-vue3": "^1.7.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", "temml": "0.10.20", "textarea-caret": "3.1.0", - "three": "0.167.0", + "three": "0.169.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "tinyld": "^1.3.4", "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.37", + "v-code-diff": "1.13.1", + "vite": "5.4.8", + "vue": "3.5.10", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { + "@biomejs/biome": "1.9.3", "@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/autosize": "^4.0.1", - "@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/prismjs": "^1.26.0", "@types/punycode": "2.1.4", - "@types/sanitize-html": "2.11.0", + "@types/qrcode": "^1.5.5", + "@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", + "@types/ws": "8.5.12", "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.0", "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.4.37", + "@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", + "cypress": "13.15.0", + "eslint-plugin-import": "2.30.0", "eslint-plugin-storybook": "^0.6.13", - "eslint-plugin-vue": "9.27.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", + "start-server-and-test": "2.0.8", + "storybook": "8.3.3", "storybook-addon-misskey-theme": "github:misskey-dev/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 d9ba7b78ad..3dbab5b8af 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 '@/Temml-Latin-Modern.css'; import { mainBoot } from '@/boot/main-boot.js'; diff --git a/packages/frontend/src/_dev_boot_.ts b/packages/frontend/src/_dev_boot_.ts index 7c6e537fbc..1601f247d7 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 ad3f4a1e5a..b454d9bb80 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -5,13 +5,13 @@ import { defineAsyncComponent, reactive, ref } from 'vue'; import * as Misskey from 'cherrypick-js'; +import { apiUrl } from '@@/js/config.js'; +import type { MenuItem, MenuButton } from '@/types/menu.js'; import * as os from '@/os.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 { del, get, set } from '@/scripts/idb-proxy.js'; -import { apiUrl } from '@/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'; @@ -327,14 +327,26 @@ 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: [{ @@ -345,64 +357,39 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link' as const, + type: 'link', icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', - }, { - type: 'button', - icon: 'ti ti-logout', - text: i18n.ts.logout, - action: async () => { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.logoutConfirm, - }); - if (canceled) return; - signout(); - }, - danger: true, - }]], ev.currentTarget ?? ev.target, { - align: 'left', - }); - } else if (opts.withExtraOperationFriendly) { - accountListFriendly($i); - } else { - popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { - align: 'left', }); - } - function accountListFriendly() { - popupMenu([...[{ - type: 'link' as const, - text: i18n.ts.profile, - to: `/@${$i.username}`, - avatar: $i, - }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent' as const, - icon: 'ti ti-plus', - text: i18n.ts.addAccount, - children: [{ - text: i18n.ts.existingAccount, - action: () => { - showSigninDialog(); - }, - }, { - text: i18n.ts.createAccount, - action: () => { - createAccount(); + if (!opts.withExtraOperationFriendly) { + menuItems.push({ + type: 'button', + icon: 'ti ti-logout', + text: i18n.ts.logout, + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.logoutConfirm, + }); + if (canceled) return; + signout(); }, - }], - }, { - type: 'link' as const, - icon: 'ti ti-users', - text: i18n.ts.manageAccounts, - to: '/settings/accounts', - }]], ev.currentTarget ?? ev.target, { - align: 'left', - }); + danger: true, + }); + } + } else { + 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 fe06585349..b1adace208 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, defineAsyncComponent } from 'vue'; import { compareVersions } from 'compare-versions'; +import { version, basedMisskeyVersion, 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, basedMisskeyVersion, 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'; import { popup } from '@/os.js'; export async function common(createVue: () => App) { @@ -65,6 +66,8 @@ export async function common(createVue: () => App) { let isClientMigrated = false; const showPushNotificationDialog = miLocalStorage.getItem('showPushNotificationDialog'); + if (miLocalStorage.getItem('ui') === null) miLocalStorage.setItem('ui', 'friendly'); + if (instance.swPublickey && ('PushManager' in window) && $i && $i.token && showPushNotificationDialog == null) { popup(defineAsyncComponent(() => import('@/components/MkPushNotification.vue')), {}, {}, 'closed'); } @@ -156,10 +159,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')); @@ -250,7 +252,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 2010b0a3b0..e0141cea24 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -4,9 +4,9 @@ */ import { createApp, defineAsyncComponent, markRaw } from 'vue'; +import { ui } from '@@/js/config.js'; import { common } from './common.js'; import type * as Misskey from 'cherrypick-js'; -import { ui } from '@/config.js'; import { i18n } from '@/i18n.js'; import { alert, confirm, popup, post, welcomeToast } 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'; import { userName } from '@/filters/user.js'; import { vibrate } from '@/scripts/vibrate.js'; @@ -71,6 +72,18 @@ export async function mainBoot() { } else if (defaultStore.state.serverDisconnectedBehavior === 'none') { /* empty */ } }); + 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 diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts index cf09c96fd4..f662c9b0f8 100644 --- a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; diff --git a/packages/frontend/src/components/MkAbuseReportResolver.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportResolver.stories.impl.ts index 241b27ff24..fcbf4b3fbc 100644 --- a/packages/frontend/src/components/MkAbuseReportResolver.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportResolver.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import MkAbuseReportResolver from './MkAbuseReportResolver.vue'; import type { StoryObj } from '@storybook/vue3'; export const Default = { diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index 9df957f3ec..285da5df8f 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index cad26de6e2..ca72f26a09 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 71b9ad306c..d4a97d3879 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -14,9 +14,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e8802e4f8f..74f5184e5f 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 19b7e218af..3a8148d68a 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -175,11 +175,11 @@ function onMousedown(evt: MouseEvent): void { background: var(--accent); &:not(:disabled):hover { - background: var(--X8); + background: hsl(from var(--accent) h s calc(l + 5)); } &:not(:disabled):active { - background: var(--X8); + background: hsl(from var(--accent) h s calc(l + 5)); } } @@ -224,15 +224,16 @@ function onMousedown(evt: MouseEvent): void { background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, var(--cherryX8), var(--pickX8)); + 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(--cherryX8), var(--pickX8)); + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); } } &.danger { + font-weight: bold; color: #ff2a2a; &.primary { @@ -250,7 +251,7 @@ function onMousedown(evt: MouseEvent): void { } &:disabled { - opacity: 0.7; + opacity: 0.5; } &:focus-visible { diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index b9770670dc..71624e7c66 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; diff --git a/packages/frontend/src/components/MkChannelList.stories.impl.ts b/packages/frontend/src/components/MkChannelList.stories.impl.ts index f69b20c049..27e2887779 100644 --- a/packages/frontend/src/components/MkChannelList.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelList.stories.impl.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; diff --git a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts index de0193c78f..9462334d4b 100644 --- a/packages/frontend/src/components/MkChannelPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelPreview.stories.impl.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ import { StoryObj } from '@storybook/vue3'; import { channel } from '../../.storybook/fakes.js'; import MkChannelPreview from './MkChannelPreview.vue'; diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index c30cb66c07..3c0874a1eb 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -117,7 +117,7 @@ const bannerStyle = computed(() => { left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); + background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); } > .name { diff --git a/packages/frontend/src/components/MkChart.stories.impl.ts b/packages/frontend/src/components/MkChart.stories.impl.ts index 1bcb9c30d8..c9786e9c11 100644 --- a/packages/frontend/src/components/MkChart.stories.impl.ts +++ b/packages/frontend/src/components/MkChart.stories.impl.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ import { StoryObj } from '@storybook/vue3'; import { http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index b06d9e02ef..24c5e376b8 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -13,29 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + diff --git a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts index e3391bcf7e..f03fc36276 100644 --- a/packages/frontend/src/components/MkDigitalClock.stories.impl.ts +++ b/packages/frontend/src/components/MkDigitalClock.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import MkDigitalClock from './MkDigitalClock.vue'; diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 19f5ae4b19..9088f4cfe0 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -36,9 +36,9 @@ 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 26a1c78ea2..5d2900fd12 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 + + diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue new file mode 100644 index 0000000000..ba82eb442f --- /dev/null +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index a433ad680b..2d8d19efe8 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect, userEvent, waitFor, within } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; import { galleryPost } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkHorizontalSwipe.vue index 196c962a06..5a31a8d8da 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkHorizontalSwipe.vue @@ -33,7 +33,6 @@ import { isHorizontalSwipeSwiping as isSwiping } from '@/scripts/touch.js'; const rootEl = shallowRef(); -// eslint-disable-next-line no-undef const tabModel = defineModel('tab'); const props = defineProps<{ diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index f5cea67abe..a1794fab13 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only > + @@ -23,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 3726ddf822..e5d2b6bc47 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -120,15 +120,16 @@ function get(): PollEditorModelValue { const calcAfter = () => { let base = parseInt(after.value.toString()); switch (unit.value) { - // @ts-expect-error fallthrough - case 'day': base *= 24; - // @ts-expect-error fallthrough - case 'hour': base *= 60; - // @ts-expect-error fallthrough - case 'minute': base *= 60; - // eslint-disable-next-line no-fallthrough - case 'second': return base *= 1000; - default: return null; + case 'day': + return base *= 24; + case 'hour': + return base *= 60; + case 'minute': + return base *= 60; + case 'second': + return base *= 1000; + default: + return null; } }; diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 8a0c7b1e54..26c251a8d2 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, shallowRef } from 'vue'; import MkModal from './MkModal.vue'; import MkMenu from './MkMenu.vue'; -import { MenuItem } from '@/types/menu.js'; +import type { MenuItem } from '@/types/menu.js'; defineProps<{ items: MenuItem[]; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 7c4f06cc4c..540af025b7 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + - - +
@@ -105,17 +105,17 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkPostFormSimple.vue b/packages/frontend/src/components/MkPostFormSimple.vue index 31a3bc6f8b..eb799e60d5 100644 --- a/packages/frontend/src/components/MkPostFormSimple.vue +++ b/packages/frontend/src/components/MkPostFormSimple.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + - - +
@@ -121,17 +121,17 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index 0ee37a8136..5f1fc8d4ea 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkSearchResultWindow.vue b/packages/frontend/src/components/MkSearchResultWindow.vue new file mode 100644 index 0000000000..6ef885b422 --- /dev/null +++ b/packages/frontend/src/components/MkSearchResultWindow.vue @@ -0,0 +1,30 @@ + + + + diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 0e81464af1..f877a7babc 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -42,11 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index d4cc1ccc96..dfc25508d2 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -63,6 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index daef04cd87..e5d72be092 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { expect, waitFor } from '@storybook/test'; import { StoryObj } from '@storybook/vue3'; diff --git a/packages/frontend/src/components/global/MkFooterSpacer.vue b/packages/frontend/src/components/global/MkFooterSpacer.vue index 1a75855fa1..4cd000039e 100644 --- a/packages/frontend/src/components/global/MkFooterSpacer.vue +++ b/packages/frontend/src/components/global/MkFooterSpacer.vue @@ -4,11 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index f2dc737c8b..5982d3b1b4 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -6,7 +6,7 @@ import { App } from 'vue'; import CPPageHeader from './global/CPPageHeader.vue'; -import Mfm from './global/MkMisskeyFlavoredMarkdown.js'; +import Mfm from './global/MkMfm.js'; import MkA from './global/MkA.vue'; import MkAcct from './global/MkAcct.vue'; import MkAvatar from './global/MkAvatar.vue'; diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index a97bee8485..66e1789b94 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue index fe1b7c561d..33d61cddd7 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -89,6 +89,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
@@ -106,6 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 0d5310bd13..74b09b600e 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -33,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index a75799696d..576aa661b2 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -10,61 +10,114 @@ SPDX-License-Identifier: AGPL-3.0-only
- + - + {{ i18n.ts.serverRules }} - - - - - - - - - - - - - - - - - + + - - - +
+ + + + {{ i18n.ts.save }} +
+ + + + - -
- +
+ + + + {{ i18n.ts.save }} +
+ + + + - -
- +
+ + + + {{ i18n.ts.save }} +
+ + + + + + +
+ + + + {{ i18n.ts.save }} +
+
+ + + - -
+ +
+ + + + {{ i18n.ts.save }} +
+ + + + + + +
+ + + + {{ i18n.ts.save }} +
+
+ + + + + +
+ + + + {{ i18n.ts.save }} +
+
+ + + + + +
+ + + + {{ i18n.ts.save }} +
+
- @@ -83,6 +136,7 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; +import MkFolder from '@/components/MkFolder.vue'; const enableRegistration = ref(false); const emailRequiredForSignup = ref(false); @@ -90,9 +144,10 @@ const sensitiveWords = ref(''); const prohibitedWords = ref(''); const hiddenTags = ref(''); const preservedUsernames = ref(''); -const tosUrl = ref(null); -const privacyPolicyUrl = ref(null); -const inquiryUrl = ref(null); +const blockedHosts = ref(''); +const silencedHosts = ref(''); +const mediaSilencedHosts = ref(''); +const trustedLinkUrlPatterns = ref(''); async function init() { const meta = await misskeyApi('admin/meta'); @@ -102,22 +157,87 @@ async function init() { prohibitedWords.value = meta.prohibitedWords.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n'); preservedUsernames.value = meta.preservedUsernames.join('\n'); - tosUrl.value = meta.tosUrl; - privacyPolicyUrl.value = meta.privacyPolicyUrl; - inquiryUrl.value = meta.inquiryUrl; + blockedHosts.value = meta.blockedHosts.join('\n'); + silencedHosts.value = meta.silencedHosts.join('\n'); + mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n'); + trustedLinkUrlPatterns.value = meta.trustedLinkUrlPatterns.join('\n'); +} + +function onChange_enableRegistration(value: boolean) { + os.apiWithDialog('admin/update-meta', { + disableRegistration: !value, + }).then(() => { + fetchInstance(true); + }); +} + +function onChange_emailRequiredForSignup(value: boolean) { + os.apiWithDialog('admin/update-meta', { + emailRequiredForSignup: value, + }).then(() => { + fetchInstance(true); + }); +} + +function save_preservedUsernames() { + os.apiWithDialog('admin/update-meta', { + preservedUsernames: preservedUsernames.value.split('\n'), + }).then(() => { + fetchInstance(true); + }); } -function save() { +function save_sensitiveWords() { os.apiWithDialog('admin/update-meta', { - disableRegistration: !enableRegistration.value, - emailRequiredForSignup: emailRequiredForSignup.value, - tosUrl: tosUrl.value, - privacyPolicyUrl: privacyPolicyUrl.value, - inquiryUrl: inquiryUrl.value, sensitiveWords: sensitiveWords.value.split('\n'), + }).then(() => { + fetchInstance(true); + }); +} + +function save_prohibitedWords() { + os.apiWithDialog('admin/update-meta', { prohibitedWords: prohibitedWords.value.split('\n'), + }).then(() => { + fetchInstance(true); + }); +} + +function save_trustedLinkUrlPatterns() { + os.apiWithDialog('admin/update-meta', { + trustedLinkUrlPatterns: trustedLinkUrlPatterns.value.split('\n'), + }).then(() => { + fetchInstance(true); + }); +} + +function save_hiddenTags() { + os.apiWithDialog('admin/update-meta', { hiddenTags: hiddenTags.value.split('\n'), - preservedUsernames: preservedUsernames.value.split('\n'), + }).then(() => { + fetchInstance(true); + }); +} + +function save_blockedHosts() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: blockedHosts.value.split('\n') || [], + }).then(() => { + fetchInstance(true); + }); +} + +function save_silencedHosts() { + os.apiWithDialog('admin/update-meta', { + silencedHosts: silencedHosts.value.split('\n') || [], + }).then(() => { + fetchInstance(true); + }); +} + +function save_mediaSilencedHosts() { + os.apiWithDialog('admin/update-meta', { + mediaSilencedHosts: mediaSilencedHosts.value.split('\n') || [], }).then(() => { fetchInstance(true); }); @@ -130,10 +250,3 @@ definePageMetadata(() => ({ icon: 'ti ti-shield', })); - - diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue deleted file mode 100644 index b5f9740937..0000000000 --- a/packages/frontend/src/pages/admin/other-settings.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts b/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts new file mode 100644 index 0000000000..584cd3e4d9 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.ap-requests.stories.impl.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import { http, HttpResponse } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { commonHandlers } from '../../../.storybook/mocks.js'; +import overview_ap_requests from './overview.ap-requests.vue'; +export const Default = { + render(args) { + return { + components: { + overview_ap_requests, + }, + setup() { + return { + args, + }; + }, + template: '', + }; + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/charts/ap-request', async ({ request }) => { + action('POST /api/charts/ap-request')(await request.json()); + return HttpResponse.json({ + deliverFailed: [0, 0, 0, 2, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 1, 0, 0, 0, 3, 1, 1, 2, 0, 0], + deliverSucceeded: [0, 1, 51, 34, 136, 189, 51, 17, 17, 34, 1, 17, 18, 51, 34, 68, 287, 0, 17, 33, 32, 96, 96, 0, 49, 64, 0, 32, 0, 32, 81, 48, 65, 1, 16, 50, 90, 148, 33, 43, 72, 127, 17, 138, 78, 91, 78, 91, 13, 52], + inboxReceived: [507, 1173, 1096, 871, 958, 937, 908, 1026, 956, 909, 807, 1002, 832, 995, 1039, 1047, 1109, 930, 711, 835, 764, 679, 835, 958, 634, 654, 691, 895, 811, 676, 1044, 1389, 1318, 863, 887, 952, 1011, 1061, 592, 900, 611, 595, 604, 562, 607, 621, 854, 666, 1197, 644], + }); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index d4c83f21b6..4bbb9210af 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; +import isChromatic from 'chromatic'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { useChartTooltip } from '@/scripts/use-chart-tooltip.js'; import { chartVLine } from '@/scripts/chart-vline.js'; @@ -41,7 +42,7 @@ const { handler: externalTooltipHandler } = useChartTooltip(); const { handler: externalTooltipHandler2 } = useChartTooltip(); onMounted(async () => { - const now = new Date(); + const now = isChromatic() ? new Date('2024-08-31T10:00:00Z') : new Date(); const getDate = (ago: number) => { const y = now.getFullYear(); @@ -51,14 +52,14 @@ onMounted(async () => { return new Date(y, m, d - ago); }; - const format = (arr) => { + const format = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v, })); }; - const formatMinus = (arr) => { + const formatMinus = (arr: number[]) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: -v, @@ -78,7 +79,6 @@ onMounted(async () => { type: 'line', data: { datasets: [{ - stack: 'a', parsing: false, label: 'Out: Succ', data: format(raw.deliverSucceeded).slice().reverse(), @@ -92,7 +92,6 @@ onMounted(async () => { fill: true, clip: 8, }, { - stack: 'a', parsing: false, label: 'Out: Fail', data: formatMinus(raw.deliverFailed).slice().reverse(), @@ -137,7 +136,6 @@ onMounted(async () => { min: getDate(chartLimit).getTime(), }, y: { - stacked: true, position: 'left', suggestedMax: 10, grid: { @@ -171,6 +169,9 @@ onMounted(async () => { duration: 0, }, external: externalTooltipHandler, + callbacks: { + label: context => `${context.dataset.label}: ${Math.abs(context.parsed.y)}`, + }, }, gradient, }, diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index bc76e0db78..6509be9be9 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -19,8 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue deleted file mode 100644 index e7f103af79..0000000000 --- a/packages/frontend/src/pages/admin/proxy-account.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - - diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue index 8d3fe35320..631dd8bacc 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -49,7 +49,9 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index a891bd47f5..f369739e27 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -118,7 +121,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -126,6 +129,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + @@ -222,7 +233,45 @@ SPDX-License-Identifier: AGPL-3.0-only - {{ i18n.ts.save }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -247,6 +296,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index c3af79d6c1..dfeda411d2 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -11,15 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
- - {{ i18n.ts._webhookSettings.createWebhook }} - - - -
- -
-
+
+ +
@@ -29,18 +23,22 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, ref } from 'vue'; import { entities } from 'cherrypick-js'; import XItem from './system-webhook.item.vue'; -import FormSection from '@/components/form/section.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import XHeader from '@/pages/admin/_header_.vue'; -import MkButton from '@/components/MkButton.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import * as os from '@/os.js'; const webhooks = ref([]); -const headerActions = computed(() => []); +const headerActions = computed(() => [{ + asFullButton: true, + icon: 'ti ti-plus', + text: i18n.ts._webhookSettings.createWebhook, + handler: onCreateWebhookClicked, +}]); + const headerTabs = computed(() => []); async function onCreateWebhookClicked() { @@ -89,8 +87,5 @@ definePageMetadata(() => ({ diff --git a/packages/frontend/src/pages/admin/update.vue b/packages/frontend/src/pages/admin/update.vue index 5a2546ccf9..e59d6eda8a 100644 --- a/packages/frontend/src/pages/admin/update.vue +++ b/packages/frontend/src/pages/admin/update.vue @@ -69,12 +69,12 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/mfm-cheat-sheet.vue b/packages/frontend/src/pages/cfm-cheat-sheet.vue similarity index 62% rename from packages/frontend/src/pages/mfm-cheat-sheet.vue rename to packages/frontend/src/pages/cfm-cheat-sheet.vue index a93d2019d7..eacd7c21a1 100644 --- a/packages/frontend/src/pages/mfm-cheat-sheet.vue +++ b/packages/frontend/src/pages/cfm-cheat-sheet.vue @@ -8,374 +8,374 @@ SPDX-License-Identifier: AGPL-3.0-only
-
{{ i18n.ts._mfm.intro }}
+
{{ i18n.ts._cfm.intro }}
-
{{ i18n.ts._mfm.mention }}
+
{{ i18n.ts._cfm.mention }}
-

{{ i18n.ts._mfm.mentionDescription }}

+

{{ i18n.ts._cfm.mentionDescription }}

- +
-
{{ i18n.ts._mfm.hashtag }}
+
{{ i18n.ts._cfm.hashtag }}
-

{{ i18n.ts._mfm.hashtagDescription }}

+

{{ i18n.ts._cfm.hashtagDescription }}

- +
-
{{ i18n.ts._mfm.url }}
+
{{ i18n.ts._cfm.url }}
-

{{ i18n.ts._mfm.urlDescription }}

+

{{ i18n.ts._cfm.urlDescription }}

- +
-
{{ i18n.ts._mfm.link }}
+
{{ i18n.ts._cfm.link }}
-

{{ i18n.ts._mfm.linkDescription }}

+

{{ i18n.ts._cfm.linkDescription }}

- +
-
{{ i18n.ts._mfm.emoji }}
+
{{ i18n.ts._cfm.emoji }}
-

{{ i18n.ts._mfm.emojiDescription }}

+

{{ i18n.ts._cfm.emojiDescription }}

- +
-
{{ i18n.ts._mfm.bold }}
+
{{ i18n.ts._cfm.bold }}
-

{{ i18n.ts._mfm.boldDescription }}

+

{{ i18n.ts._cfm.boldDescription }}

- +
-
{{ i18n.ts._mfm.small }}
+
{{ i18n.ts._cfm.small }}
-

{{ i18n.ts._mfm.smallDescription }}

+

{{ i18n.ts._cfm.smallDescription }}

- +
-
{{ i18n.ts._mfm.quote }}
+
{{ i18n.ts._cfm.quote }}
-

{{ i18n.ts._mfm.quoteDescription }}

+

{{ i18n.ts._cfm.quoteDescription }}

- +
-
{{ i18n.ts._mfm.center }}
+
{{ i18n.ts._cfm.center }}
-

{{ i18n.ts._mfm.centerDescription }}

+

{{ i18n.ts._cfm.centerDescription }}

- +
-
{{ i18n.ts._mfm.inlineCode }}
+
{{ i18n.ts._cfm.inlineCode }}
-

{{ i18n.ts._mfm.inlineCodeDescription }}

+

{{ i18n.ts._cfm.inlineCodeDescription }}

- +
-
{{ i18n.ts._mfm.blockCode }}
+
{{ i18n.ts._cfm.blockCode }}
-

{{ i18n.ts._mfm.blockCodeDescription }}

+

{{ i18n.ts._cfm.blockCodeDescription }}

- +
-
{{ i18n.ts._mfm.inlineMath }}
+
{{ i18n.ts._cfm.inlineMath }}
-

{{ i18n.ts._mfm.inlineMathDescription }}

+

{{ i18n.ts._cfm.inlineMathDescription }}

- +
-
{{ i18n.ts._mfm.blockMath }}
+
{{ i18n.ts._cfm.blockMath }}
-

{{ i18n.ts._mfm.blockMathDescription }}

+

{{ i18n.ts._cfm.blockMathDescription }}

- +
-
{{ i18n.ts._mfm.search }}
+
{{ i18n.ts._cfm.search }}
-

{{ i18n.ts._mfm.searchDescription }}

+

{{ i18n.ts._cfm.searchDescription }}

- +
-
{{ i18n.ts._mfm.flip }}
+
{{ i18n.ts._cfm.flip }}
-

{{ i18n.ts._mfm.flipDescription }}

+

{{ i18n.ts._cfm.flipDescription }}

- +
-
{{ i18n.ts._mfm.font }}
+
{{ i18n.ts._cfm.font }}
-

{{ i18n.ts._mfm.fontDescription }}

+

{{ i18n.ts._cfm.fontDescription }}

- +
-
{{ i18n.ts._mfm.x2 }}
+
{{ i18n.ts._cfm.x2 }}
-

{{ i18n.ts._mfm.x2Description }}

+

{{ i18n.ts._cfm.x2Description }}

- +
-
{{ i18n.ts._mfm.x3 }}
+
{{ i18n.ts._cfm.x3 }}
-

{{ i18n.ts._mfm.x3Description }}

+

{{ i18n.ts._cfm.x3Description }}

- +
-
{{ i18n.ts._mfm.x4 }}
+
{{ i18n.ts._cfm.x4 }}
-

{{ i18n.ts._mfm.x4Description }}

+

{{ i18n.ts._cfm.x4Description }}

- +
-
{{ i18n.ts._mfm.blur }}
+
{{ i18n.ts._cfm.blur }}
-

{{ i18n.ts._mfm.blurDescription }}

+

{{ i18n.ts._cfm.blurDescription }}

- +
-
{{ i18n.ts._mfm.jelly }}
+
{{ i18n.ts._cfm.jelly }}
-

{{ i18n.ts._mfm.jellyDescription }}

+

{{ i18n.ts._cfm.jellyDescription }}

- +
-
{{ i18n.ts._mfm.tada }}
+
{{ i18n.ts._cfm.tada }}
-

{{ i18n.ts._mfm.tadaDescription }}

+

{{ i18n.ts._cfm.tadaDescription }}

- +
-
{{ i18n.ts._mfm.jump }}
+
{{ i18n.ts._cfm.jump }}
-

{{ i18n.ts._mfm.jumpDescription }}

+

{{ i18n.ts._cfm.jumpDescription }}

- +
-
{{ i18n.ts._mfm.bounce }}
+
{{ i18n.ts._cfm.bounce }}
-

{{ i18n.ts._mfm.bounceDescription }}

+

{{ i18n.ts._cfm.bounceDescription }}

- +
-
{{ i18n.ts._mfm.spin }}
+
{{ i18n.ts._cfm.spin }}
-

{{ i18n.ts._mfm.spinDescription }}

+

{{ i18n.ts._cfm.spinDescription }}

- +
-
{{ i18n.ts._mfm.shake }}
+
{{ i18n.ts._cfm.shake }}
-

{{ i18n.ts._mfm.shakeDescription }}

+

{{ i18n.ts._cfm.shakeDescription }}

- +
-
{{ i18n.ts._mfm.twitch }}
+
{{ i18n.ts._cfm.twitch }}
-

{{ i18n.ts._mfm.twitchDescription }}

+

{{ i18n.ts._cfm.twitchDescription }}

- +
-
{{ i18n.ts._mfm.rainbow }}
+
{{ i18n.ts._cfm.rainbow }}
-

{{ i18n.ts._mfm.rainbowDescription }}

+

{{ i18n.ts._cfm.rainbowDescription }}

- +
-
{{ i18n.ts._mfm.sparkle }}
+
{{ i18n.ts._cfm.sparkle }}
-

{{ i18n.ts._mfm.sparkleDescription }}

+

{{ i18n.ts._cfm.sparkleDescription }}

- MFM {{ i18n.ts.sample }} + CFM {{ i18n.ts.sample }}
-
{{ i18n.ts._mfm.fade }}
+
{{ i18n.ts._cfm.fade }}
-

{{ i18n.ts._mfm.fadeDescription }}

+

{{ i18n.ts._cfm.fadeDescription }}

- +
-
{{ i18n.ts._mfm.rotate }}
+
{{ i18n.ts._cfm.rotate }}
-

{{ i18n.ts._mfm.rotateDescription }}

+

{{ i18n.ts._cfm.rotateDescription }}

- MFM {{ i18n.ts.sample }} + CFM {{ i18n.ts.sample }}
-
{{ i18n.ts._mfm.position }}
+
{{ i18n.ts._cfm.position }}
-

{{ i18n.ts._mfm.positionDescription }}

+

{{ i18n.ts._cfm.positionDescription }}

- +
-
{{ i18n.ts._mfm.scale }}
+
{{ i18n.ts._cfm.scale }}
-

{{ i18n.ts._mfm.scaleDescription }}

+

{{ i18n.ts._cfm.scaleDescription }}

- +
-
{{ i18n.ts._mfm.fg }}
+
{{ i18n.ts._cfm.fg }}
-

{{ i18n.ts._mfm.fgDescription }}

+

{{ i18n.ts._cfm.fgDescription }}

- +
-
{{ i18n.ts._mfm.bg }}
+
{{ i18n.ts._cfm.bg }}
-

{{ i18n.ts._mfm.bgDescription }}

+

{{ i18n.ts._cfm.bgDescription }}

- +
-
{{ i18n.ts._mfm.plain }}
+
{{ i18n.ts._cfm.plain }}
-

{{ i18n.ts._mfm.plainDescription }}

+

{{ i18n.ts._cfm.plainDescription }}

- MFM {{ i18n.ts.sample }} + CFM {{ i18n.ts.sample }}
-
{{ i18n.ts._mfm.ruby }}
+
{{ i18n.ts._cfm.ruby }}
-

{{ i18n.ts._mfm.rubyDescription }}

+

{{ i18n.ts._cfm.rubyDescription }}

- MFM {{ i18n.ts.sample }} + CFM {{ i18n.ts.sample }}
@@ -398,17 +398,17 @@ defineProps<{ const preview_mention = ref('@example'); const preview_hashtag = ref('#test'); const preview_url = ref('https://example.com'); -const preview_link = ref(`[${i18n.ts._mfm.dummy}](https://example.com)`); +const preview_link = ref(`[${i18n.ts._cfm.dummy}](https://example.com)`); const preview_emoji = ref(customEmojis.value.length ? `:${customEmojis.value[0].name}:` : ':emojiname:'); -const preview_bold = ref(`**${i18n.ts._mfm.dummy}**`); -const preview_small = ref(`${i18n.ts._mfm.dummy}`); -const preview_center = ref(`
${i18n.ts._mfm.dummy}
`); +const preview_bold = ref(`**${i18n.ts._cfm.dummy}**`); +const preview_small = ref(`${i18n.ts._cfm.dummy}`); +const preview_center = ref(`
${i18n.ts._cfm.dummy}
`); const preview_inlineCode = ref('`<: "Hello, world!"`'); const preview_blockCode = ref('```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'); const preview_inlineMath = ref('\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)'); const preview_blockMath = ref('\\(x=0\\\\ y=1\\\\ z=2\\)'); -const preview_quote = ref(`> ${i18n.ts._mfm.dummy}`); -const preview_search = ref(`${i18n.ts._mfm.dummy} 検索\n${i18n.ts._mfm.dummy} 검색\n${i18n.ts._mfm.dummy} search`); +const preview_quote = ref(`> ${i18n.ts._cfm.dummy}`); +const preview_search = ref(`${i18n.ts._cfm.dummy} [検索]\n${i18n.ts._cfm.dummy} [검색]\n${i18n.ts._cfm.dummy} [search]`); const preview_jelly = ref('$[jelly 🍮] $[jelly.speed=5s 🍮]'); const preview_tada = ref('$[tada 🍮] $[tada.speed=5s 🍮]'); const preview_jump = ref('$[jump 🍮] $[jump.speed=5s 🍮]'); @@ -416,12 +416,12 @@ const preview_bounce = ref('$[bounce 🍮] $[bounce.speed=5s 🍮]'); const preview_shake = ref('$[shake 🍮] $[shake.speed=5s 🍮]'); const preview_twitch = ref('$[twitch 🍮] $[twitch.speed=5s 🍮]'); const preview_spin = ref('$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]'); -const preview_flip = ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`); -const preview_font = ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`); +const preview_flip = ref(`$[flip ${i18n.ts._cfm.dummy}]\n$[flip.v ${i18n.ts._cfm.dummy}]\n$[flip.h,v ${i18n.ts._cfm.dummy}]`); +const preview_font = ref(`$[font.serif ${i18n.ts._cfm.dummy}]\n$[font.monospace ${i18n.ts._cfm.dummy}]\n$[font.cursive ${i18n.ts._cfm.dummy}]\n$[font.fantasy ${i18n.ts._cfm.dummy}]`); const preview_x2 = ref('$[x2 🍮]'); const preview_x3 = ref('$[x3 🍮]'); const preview_x4 = ref('$[x4 🍮]'); -const preview_blur = ref(`$[blur ${i18n.ts._mfm.dummy}]`); +const preview_blur = ref(`$[blur ${i18n.ts._cfm.dummy}]`); const preview_rainbow = ref('$[rainbow 🍮] $[rainbow.speed=5s 🍮]'); const preview_sparkle = ref('$[sparkle 🍮]'); const preview_fade = ref('$[fade 🍮] $[fade.speed=1.5s 🍮]'); @@ -438,7 +438,7 @@ const headerActions = computed(() => []); const headerTabs = computed(() => []); definePageMetadata(() => ({ - title: i18n.ts._mfm.cheatSheet, + title: i18n.ts._cfm.cheatSheet, icon: 'ti ti-help-circle', })); @@ -462,7 +462,7 @@ definePageMetadata(() => ({ font-weight: bold; -webkit-backdrop-filter: var(--blur, blur(10px)); backdrop-filter: var(--blur, blur(10px)); - background-color: var(--X16); + background-color: var(--panel); } .content { diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 0a1a0e958b..d7a5225199 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -72,6 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only + diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index d6710c2d82..83d9d2d656 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ flash.likedCount }} {{ flash.likedCount }} +
@@ -65,10 +66,11 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef, defineAsyncComponent } from 'vue'; import * as Misskey from 'cherrypick-js'; import { Interpreter, Parser, values } from '@syuilo/aiscript'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkAsUi from '@/components/MkAsUi.vue'; @@ -80,7 +82,6 @@ import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { MenuItem } from '@/types/menu'; import { pleaseLogin } from '@/scripts/please-login.js'; const props = defineProps<{ @@ -104,18 +105,23 @@ function fetchFlash() { function share(ev: MouseEvent) { if (!flash.value) return; - os.popupMenu([ - { - text: i18n.ts.shareWithNote, - icon: 'ti ti-pencil', - action: shareWithNote, - }, - ...(isSupportShare() ? [{ + const menuItems: MenuItem[] = []; + + menuItems.push({ + text: i18n.ts.shareWithNote, + icon: 'ti ti-pencil', + action: shareWithNote, + }); + + if (isSupportShare()) { + menuItems.push({ text: i18n.ts.share, icon: 'ti ti-share', action: shareWithNavigator, - }] : []), - ], ev.currentTarget ?? ev.target); + }); + } + + os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } function copyLink() { @@ -125,6 +131,11 @@ function copyLink() { os.success(); } +function shareQRCode() { + if (!flash.value) return; + os.displayQRCode(`${url}/play/${flash.value.id}`); +} + function shareWithNavigator() { if (!flash.value) return; diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index b218c33b61..92ad710422 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
@@ -65,6 +66,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 8bb7bb994b..e76d97f8db 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -113,6 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref, onMounted, shallowRef, onUnmounted } from 'vue'; import * as Misskey from 'cherrypick-js'; import * as Reversi from 'misskey-reversi'; +import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { signinRequired } from '@/account.js'; import { deepClone } from '@/scripts/clone.js'; @@ -121,7 +122,6 @@ import MkRadios from '@/components/MkRadios.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { MenuItem } from '@/types/menu.js'; import { useRouter } from '@/router/supplier.js'; const $i = signinRequired(); diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 5e04312365..6e106b8dc2 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -12,6 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue index 78bb716690..8f8de22370 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue @@ -108,6 +108,7 @@ const decorationsForPreview = computed(() => { flipH: flipH.value, offsetX: offsetX.value, offsetY: offsetY.value, + blink: true, scale: scale.value, opacity: opacity.value, }; diff --git a/packages/frontend/src/pages/settings/cherrypick.vue b/packages/frontend/src/pages/settings/cherrypick.vue index bf39699db7..1f699befb4 100644 --- a/packages/frontend/src/pages/settings/cherrypick.vue +++ b/packages/frontend/src/pages/settings/cherrypick.vue @@ -67,15 +67,22 @@ SPDX-License-Identifier: AGPL-3.0-only - +
- {{ i18n.ts.friendlyEnableNotifications }} - {{ i18n.ts.friendlyEnableWidgets }} - - - - - {{ i18n.ts._cherrypick.friendlyShowAvatarDecorationsInNavBtn }} + {{ i18n.ts._cherrypick.enableWidgetsArea }} + +
+
Friendly UI
+ + + {{ i18n.ts._cherrypick.friendlyUiEnableNotificationsArea }} + + + + + + {{ i18n.ts._cherrypick.friendlyUiShowAvatarDecorationsInNavBtn }} +
@@ -95,20 +102,9 @@ import MkSelect from '@/components/MkSelect.vue'; import MkRadios from '@/components/MkRadios.vue'; import FormSection from '@/components/form/section.vue'; import { defaultStore } from '@/store.js'; -import * as os from '@/os.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; - -async function reloadAsk() { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} +import { reloadAsk } from '@/scripts/reload-ask.js'; const nicknameEnabled = computed(defaultStore.makeGetterSetter('nicknameEnabled')); const useEnterToSend = computed(defaultStore.makeGetterSetter('useEnterToSend')); @@ -121,10 +117,10 @@ const reactableRemoteReactionEnabled = computed(defaultStore.makeGetterSetter('r const showFollowingMessageInsteadOfButtonEnabled = computed(defaultStore.makeGetterSetter('showFollowingMessageInsteadOfButtonEnabled')); const mobileHeaderChange = computed(defaultStore.makeGetterSetter('mobileHeaderChange')); const renameTheButtonInPostFormToNya = computed(defaultStore.makeGetterSetter('renameTheButtonInPostFormToNya')); -const friendlyEnableNotifications = computed(defaultStore.makeGetterSetter('friendlyEnableNotifications')); -const friendlyEnableWidgets = computed(defaultStore.makeGetterSetter('friendlyEnableWidgets')); +const enableWidgetsArea = computed(defaultStore.makeGetterSetter('enableWidgetsArea')); +const friendlyUiEnableNotificationsArea = computed(defaultStore.makeGetterSetter('friendlyUiEnableNotificationsArea')); const enableLongPressOpenAccountMenu = computed(defaultStore.makeGetterSetter('enableLongPressOpenAccountMenu')); -const friendlyShowAvatarDecorationsInNavBtn = computed(defaultStore.makeGetterSetter('friendlyShowAvatarDecorationsInNavBtn')); +const friendlyUiShowAvatarDecorationsInNavBtn = computed(defaultStore.makeGetterSetter('friendlyUiShowAvatarDecorationsInNavBtn')); watch([ renameTheButtonInPostFormToNya, @@ -137,10 +133,10 @@ watch([ reactableRemoteReactionEnabled, mobileHeaderChange, renameTheButtonInPostFormToNya, - friendlyEnableNotifications, - friendlyEnableWidgets, + enableWidgetsArea, + friendlyUiEnableNotificationsArea, ], async () => { - await reloadAsk(); + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/emoji-picker.vue b/packages/frontend/src/pages/settings/emoji-picker.vue index dc3e3ee503..999a73df4c 100644 --- a/packages/frontend/src/pages/settings/emoji-picker.vue +++ b/packages/frontend/src/pages/settings/emoji-picker.vue @@ -113,10 +113,13 @@ SPDX-License-Identifier: AGPL-3.0-only - - {{ i18n.ts.useDrawerReactionPickerForMobile }} + + - + + + +
@@ -128,7 +131,7 @@ import Sortable from 'vuedraggable'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -146,7 +149,7 @@ const pinnedEmojis: Ref = ref(deepClone(defaultStore.state.pinnedEmoji const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale')); const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth')); const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight')); -const emojiPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('emojiPickerUseDrawerForMobile')); +const emojiPickerStyle = computed(defaultStore.makeGetterSetter('emojiPickerStyle')); const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev); const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev); diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 8b31a3d4a9..b0cea0b539 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -17,13 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - - - - @@ -45,220 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only - - - -
-
- - - - - {{ i18n.ts.collapseDefault }} CherryPick - {{ i18n.ts.showNoteActionsOnlyHover }} - {{ i18n.ts.showClipButtonInNoteFooter }} - {{ i18n.ts.showTranslateButtonInNote }} CherryPick - {{ i18n.ts.enableAdvancedMfm }} - {{ i18n.ts.enableAnimatedMfm }} -
-
- -
-
- -
-
- {{ i18n.ts.enableQuickAddMfmFunction }} - {{ i18n.ts.showReactionsCount }} - {{ i18n.ts.showGapBetweenNotesInTimeline }} - {{ i18n.ts.loadRawImages }} - - - - - - - {{ i18n.ts.limitWidthOfReaction }} - {{ i18n.ts.hideAvatarsInNote }} CherryPick - {{ i18n.ts.enableAbsoluteTime }} CherryPick - {{ i18n.ts.enableMarkByDate }} CherryPick - {{ i18n.ts.showSubNoteFooterButton }} CherryPick - {{ i18n.ts.infoButtonForNoteActions }} CherryPick - {{ i18n.ts.showReplyInNotification }} CherryPick - {{ i18n.ts.renoteQuoteButtonSeparation }} CherryPick - {{ i18n.ts.showRenoteVisibilitySelector }} CherryPick - - - - - - - - {{ i18n.ts.showFixedPostFormInReplies }} CherryPick - {{ i18n.ts.allMediaNoteCollapse }} CherryPick -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - -
- {{ i18n.ts.useGroupedNotifications }} - - - - - - - - - - - - - - - - {{ i18n.ts._notification.checkNotificationBehavior }} -
-
- - - - -
-
- {{ i18n.ts.reduceUiAnimation }} - {{ i18n.ts.useBlurEffect }} - {{ i18n.ts.useBlurEffectForModal }} - {{ i18n.ts.removeModalBgColorForBlur }} CherryPick - {{ i18n.ts.disableShowingAnimatedImages }} - - - - - - - {{ i18n.ts.highlightSensitiveMedia }} - {{ i18n.ts.squareAvatars }} - {{ i18n.ts.showAvatarDecorations }} - {{ i18n.ts.useSystemFont }} - {{ i18n.ts.disableDrawer }} - {{ i18n.ts.forceShowAds }} - {{ i18n.ts.seasonalScreenEffect }} - {{ i18n.ts.useNativeUIForVideoAudioPlayer }} - {{ i18n.ts.showUnreadNotificationsCount }} -
-
- - - - - - -
-
- - - -
-
{{ i18n.ts.fontSize }} CherryPick
-
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
{{ i18n.ts._mfm.dummy }}
-
-
-
Aa
- - -
Aa
-
- {{ i18n.ts.reloadToApplySetting2 }} - {{ i18n.ts.useBoldFont }} -
-
-
- @@ -272,6 +51,12 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.enableHorizontalSwipe }} {{ i18n.ts.alwaysConfirmFollow }} {{ i18n.ts.confirmWhenRevealingSensitiveMedia }} + {{ i18n.ts.autoLoadMoreReplies }} CherryPick + {{ i18n.ts.autoLoadMoreConversation }} CherryPick + + {{ i18n.ts.useAutoTranslate }} CherryPick + + @@ -339,6 +124,12 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + +
@@ -356,8 +147,8 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue index 9bb3957a84..5acbc50756 100644 --- a/packages/frontend/src/pages/settings/import-export.vue +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }}
- + @@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} @@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} @@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} @@ -108,7 +108,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} - + {{ i18n.ts.import }} diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 43e9c6b0af..92d3be9806 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- + @@ -105,6 +105,11 @@ const menuDef = computed(() => [{ text: i18n.ts.general, to: '/settings/general', active: currentPage.value?.route.name === 'general', + }, { + icon: 'ti ti-brush', + text: i18n.ts.appearance, + to: '/settings/appearance', + active: currentPage.value?.route.name === 'appearance', }, { icon: 'ti ti-palette', text: i18n.ts.theme, diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index 4fb75f8fee..8c31eca353 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -52,6 +52,25 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + +
+ {{ i18n.ts.menu }} + {{ i18n.ts.home }} + {{ i18n.ts.explore }} + {{ i18n.ts.search }} + {{ i18n.ts.notifications }} + {{ i18n.ts.messaging }} + {{ i18n.ts.widgets }} + {{ i18n.ts.postNote }} +
+
+ {{ i18n.ts.default }} + {{ i18n.ts.save }} +
+
@@ -61,13 +80,24 @@ import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSlot from '@/components/form/slot.vue'; import MkContainer from '@/components/MkContainer.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; import { defaultStore } from '@/store.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { reloadAsk } from '@/scripts/reload-ask.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { globalEvents } from '@/events.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { deviceKind } from '@/scripts/device-kind.js'; + +const MOBILE_THRESHOLD = 500; + +const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); +window.addEventListener('resize', () => { + isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD; +}); +const isFriendly = ref(miLocalStorage.getItem('ui') === 'friendly'); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -79,17 +109,14 @@ const items = ref(defaultStore.state.menu.map(x => ({ const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); const bannerDisplay = computed(defaultStore.makeGetterSetter('bannerDisplay')); -async function reloadAsk() { - if (defaultStore.state.requireRefreshBehavior === 'dialog') { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); - } else globalEvents.emit('hasRequireRefresh', true); -} +const showMenuButtonInNavbar = computed(defaultStore.makeGetterSetter('showMenuButtonInNavbar')); +const showHomeButtonInNavbar = computed(defaultStore.makeGetterSetter('showHomeButtonInNavbar')); +const showExploreButtonInNavbar = computed(defaultStore.makeGetterSetter('showExploreButtonInNavbar')); +const showSearchButtonInNavbar = computed(defaultStore.makeGetterSetter('showSearchButtonInNavbar')); +const showNotificationButtonInNavbar = computed(defaultStore.makeGetterSetter('showNotificationButtonInNavbar')); +const showMessageButtonInNavbar = computed(defaultStore.makeGetterSetter('showMessageButtonInNavbar')); +const showWidgetButtonInNavbar = computed(defaultStore.makeGetterSetter('showWidgetButtonInNavbar')); +const showPostButtonInNavbar = computed(defaultStore.makeGetterSetter('showPostButtonInNavbar')); async function addItem(ev: MouseEvent) { const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); @@ -125,7 +152,7 @@ function removeItem(index: number) { async function save() { defaultStore.set('menu', items.value.map(x => x.type)); - await reloadAsk(); + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); } function reset() { @@ -135,8 +162,24 @@ function reset() { })); } +function resetButtomNavbar() { + defaultStore.set('showHomeButtonInNavbar', !isFriendly.value); + defaultStore.set('showExploreButtonInNavbar', isFriendly.value); + defaultStore.set('showSearchButtonInNavbar', false); + defaultStore.set('showNotificationButtonInNavbar', true); + defaultStore.set('showMessageButtonInNavbar', isFriendly.value); + defaultStore.set('showWidgetButtonInNavbar', true); +} + +function learnMoreBottomNavbar() { + os.alert({ + type: 'info', + text: i18n.ts.bottomNavbarDescription, + }); +} + watch([menuDisplay, bannerDisplay], async () => { - await reloadAsk(); + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 9ba0b8dfd4..c6c71d4e28 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index f1f98c7624..979fbf5038 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 }} +
+
@@ -126,9 +134,9 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; import { globalEvents } from '@/events.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; import MkInfo from '@/components/MkInfo.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +import { reloadAsk } from '@/scripts/reload-ask.js'; const $i = signinRequired(); @@ -139,6 +147,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, @@ -186,6 +195,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, @@ -208,10 +219,10 @@ function save() { claimAchievement('markedAsCat'); defaultStore.set('renameTheButtonInPostFormToNya', true); defaultStore.set('renameTheButtonInPostFormToNyaManualSet', false); - reloadAsk(); + reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); } else if (!profile.isCat && !defaultStore.state.renameTheButtonInPostFormToNyaManualSet) { defaultStore.set('renameTheButtonInPostFormToNya', false); - reloadAsk(); + reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); } } @@ -268,16 +279,6 @@ function changeBanner(ev) { }); } -async function reloadAsk() { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/settings/sounds-and-vibrations.vue b/packages/frontend/src/pages/settings/sounds-and-vibrations.vue index eb5de803a4..d286456006 100644 --- a/packages/frontend/src/pages/settings/sounds-and-vibrations.vue +++ b/packages/frontend/src/pages/settings/sounds-and-vibrations.vue @@ -66,7 +66,7 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { operationTypes } from '@/scripts/sound.js'; import { defaultStore } from '@/store.js'; -import { unisonReload } from '@/scripts/unison-reload.js'; +import { reloadAsk } from '@/scripts/reload-ask.js'; const ua = /ipad|iphone/.test(navigator.userAgent.toLowerCase()) || !window.navigator.vibrate; @@ -91,16 +91,6 @@ const vibrateChat = computed(defaultStore.makeGetterSetter('vibrateChat')); const vibrateChatBg = computed(defaultStore.makeGetterSetter('vibrateChatBg')); const vibrateSystem = computed(defaultStore.makeGetterSetter('vibrateSystem')); -async function reloadAsk() { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -} - function getSoundTypeName(f: SoundType): string { switch (f) { case null: @@ -148,7 +138,7 @@ function learnMorePlayVibrations() { watch([ vibrateSystem, ], async () => { - await reloadAsk(); + await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true }); }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 7d192bcbea..b38e94b0c1 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -73,6 +73,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 9b77392872..efb5d634c2 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 50c3beeabc..7ae6e529ba 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -79,6 +79,9 @@ 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 { host } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -86,9 +89,6 @@ 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 * 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 27c4035bfc..05d0453b75 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -58,13 +58,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/user/index.timeline.files.vue b/packages/frontend/src/pages/user/index.timeline.files.vue new file mode 100644 index 0000000000..3782a49867 --- /dev/null +++ b/packages/frontend/src/pages/user/index.timeline.files.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 46fa7b2768..3d6f11e36f 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only + @@ -31,8 +32,10 @@ import * as Misskey from 'cherrypick-js'; import MkNotes from '@/components/MkNotes.vue'; import MkTab from '@/components/MkTab.vue'; import XReactions from '@/pages/user/reactions.vue'; +import XFiles from '@/pages/user/index.timeline.files.vue'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; +import { defaultStore } from '@/store.js'; const props = defineProps<{ user: Misskey.entities.UserDetailed; @@ -46,6 +49,13 @@ const pagination = computed(() => tab.value === 'featured' ? { params: { userId: props.user.id, }, +} : tab.value === 'files' && defaultStore.state.filesGridLayoutInUserPage ? { + endpoint: 'users/notes' as const, + limit: 30, + params: { + userId: props.user.id, + withFiles: true, + }, } : { endpoint: 'users/notes' as const, limit: 10, diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue index a1eaca5686..59f944d304 100644 --- a/packages/frontend/src/pages/user/lists.vue +++ b/packages/frontend/src/pages/user/lists.vue @@ -19,7 +19,7 @@ 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 2093193e81..5fce5c0c85 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -5,22 +5,24 @@ import { defineAsyncComponent, Ref, ShallowRef } from 'vue'; import * as Misskey from 'cherrypick-js'; +import { url } from '@@/js/config.js'; +import { shouldCollapsed } from '@@/js/collapsed.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'; import { addDividersBetweenMenuSections } from '@/scripts/add-dividers-between-menu-sections.js'; export async function getNoteClipMenu(props: { @@ -67,6 +69,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', @@ -94,11 +101,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, }, @@ -154,8 +163,22 @@ 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; + collapsed?: Ref; translation: Ref; translating: Ref; viewTextSource: Ref; @@ -213,7 +236,7 @@ export function getNoteMenu(props: { text: i18n.ts.disableNoteEditConfirmWarn, actions: [ { - value: 'yes' as const, + value: 'ok' as const, text: i18n.ts.disableNoteEditOk, }, { @@ -222,14 +245,15 @@ export function getNoteMenu(props: { danger: true, }, { - value: 'no' as const, + value: 'cancel' as const, text: i18n.ts.cancel, primary: true, }, ], }); + if (confirm.canceled) return; - if (confirm.result === 'no') return; + if (confirm.result === 'cancel') return; if (confirm.result === 'neverShow') { miLocalStorage.setItem('neverShowNoteEditInfo', 'true'); @@ -301,7 +325,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, @@ -335,6 +359,7 @@ export function getNoteMenu(props: { async function translate(): Promise { if (props.translation.value != null) return; + if (props.collapsed?.value != null) props.collapsed.value = false; props.translating.value = true; const res = await misskeyApi('notes/translate', { noteId: appearNote.id, @@ -356,79 +381,112 @@ export function getNoteMenu(props: { props.noNyaize.value = false; } - 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' }] : [] - ), ...(isSupportShare() ? [{ + if (props.currentClip?.userId === $i.id) { + menuItems.push({ + icon: 'ti ti-backspace', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, { type: 'divider' }); + } + + if (isSupportShare()) { + menuItems.push({ icon: 'ti ti-share', text: i18n.ts.share, action: share, - }] : []), - getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink) - , { - icon: 'ti ti-copy', - text: i18n.ts.copyContent, - action: copyContent, - }, { - icon: 'ti ti-external-link', - text: i18n.ts.openInNewTab, - action: openInNewTab, + }); + } + + getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink); + + menuItems.push({ + icon: 'ti ti-qrcode', + text: i18n.ts.getQRCode, + action: () => { + os.displayQRCode(`${url}/notes/${appearNote.id}`); }, - $i && $i.policies.canUseTranslator && instance.translatorAvailable ? { + }); + + menuItems.push({ + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-external-link', + text: i18n.ts.openInNewTab, + action: openInNewTab, + }); + + const isLong = shouldCollapsed(appearNote, []); + if ($i.policies.canUseTranslator && instance.translatorAvailable && (!defaultStore.state.useAutoTranslate || (defaultStore.state.useAutoTranslate && !$i.policies.canUseAutoTranslate && (isLong || appearNote.cw != null)))) { + 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), - }, - 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-note', - text: i18n.ts.note, - children: [{ + }); + } + + 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-note', + text: i18n.ts.note, + children: async () => { + const noteChildMenu = [] as MenuItem[]; + + noteChildMenu.push({ icon: 'ti ti-info-circle', text: i18n.ts.details, action: openDetail, @@ -440,122 +498,134 @@ export function getNoteMenu(props: { icon: 'ti ti-icons', text: i18n.ts.reactionsList, action: showReactions, - }, (appearNote.url ?? appearNote.uri) ? { - icon: 'ti ti-external-link', - text: i18n.ts.showOnRemote, - action: () => { - window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); - }, - } : undefined - , { type: 'divider' } - , { + }); + + if (appearNote.url ?? appearNote.uri) { + noteChildMenu.push({ + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); + }, + }); + } else { + noteChildMenu.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + } + + noteChildMenu.push({ type: 'divider' }); + + noteChildMenu.push({ icon: 'ti ti-source-code', text: i18n.ts.viewTextSource, action: showViewTextSource, + }); + + if (props.noNyaize.value) { + noteChildMenu.push({ + icon: 'ti ti-paw-filled', + text: i18n.ts.revertNoNyaization, + action: revertNoNyaizeText, + }); + } else { + noteChildMenu.push({ + icon: 'ti ti-paw-off', + text: i18n.ts.noNyaization, + action: noNyaizeText, + }); } - , props.noNyaize.value ? { - icon: 'ti ti-paw-filled', - text: i18n.ts.revertNoNyaization, - action: revertNoNyaizeText, - } : { - icon: 'ti ti-paw-off', - text: i18n.ts.noNyaization, - action: noNyaizeText, + + if (appearNote.userId === $i.id) { + noteChildMenu.push({ type: 'divider' }); + noteChildMenu.push({ + icon: 'ti ti-edit-circle', + text: i18n.ts.copyAndEdit, + action: copyEdit, + }); } - , (appearNote.userId === $i.id) ? { type: 'divider' } : undefined - , (appearNote.userId === $i.id) ? { - icon: 'ti ti-edit-circle', - text: i18n.ts.copyAndEdit, - action: copyEdit, - } : undefined], + + return noteChildMenu; }, - { - type: 'parent' as const, - 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; - }, + }); + + 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; }, - /* - ...($i.isModerator || $i.isAdmin ? [ - { type: 'divider' }, - { - icon: 'ti ti-speakerphone', - text: i18n.ts.promote, - action: promote - }] - : [] - ),*/ - ...(appearNote.userId !== $i.id || props.note.userId !== $i.id ? [ - { type: 'divider' }, - appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined, - props.note.userId !== $i.id ? getAbuseNoteMenu(props.note, i18n.ts.reportAbuseRenote) : 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; - }, + }); + + if (appearNote.userId !== $i.id || props.note.userId !== $i.id) { + menuItems.push({ type: 'divider' }); + if (appearNote.userId !== $i.id) menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse)); + if (props.note.userId !== $i.id) menuItems.push(getAbuseNoteMenu(props.note, i18n.ts.reportAbuseRenote)); + } + + 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 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 && $i.policies.canEditNote ? { - icon: 'ti ti-edit', - text: i18n.ts.edit, - action: edit, - } : undefined, - appearNote.userId === $i.id ? { + }); + } + + if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) { + menuItems.push({ type: 'divider' }); + if (appearNote.userId === $i.id) { + if ($i.policies.canEditNote) { + menuItems.push({ + icon: 'ti ti-edit', + text: i18n.ts.edit, + action: edit, + }); + } + + menuItems.push({ icon: 'ti ti-eraser', text: i18n.ts.deleteAndEdit, action: delEdit, - } : undefined, - { - icon: 'ti ti-trash', - text: i18n.ts.delete, - danger: true, - action: del, - }] - : [] - )] - .filter(x => x !== undefined); + }); + } + 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, @@ -563,36 +633,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.toast(i18n.ts.copied, 'copied'); }, - }]); + }); } const cleanup = () => { @@ -603,7 +679,7 @@ export function getNoteMenu(props: { }; return { - menu, + menu: menuItems, cleanup, }; } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index ffb232f5ff..2925397b0b 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -6,20 +6,20 @@ import { toUnicode } from 'punycode'; import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'cherrypick-js'; +import { host, url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -import { host, url } from '@/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore, userActions } from '@/store.js'; -import { $i, iAmModerator } from '@/account.js'; +import { $i, iAmAdmin, iAmModerator } from '@/account.js'; import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js'; 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 { editNickname } from '@/scripts/edit-nickname.js'; -import { globalEvents } from '@/events.js'; export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { const meId = $i ? $i.id : null; @@ -48,6 +48,62 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } + const meta = ref(null); + const instance = ref(null); + + const isInstanceBlocked = ref(false); + const isInstanceSilenced = ref(false); + const isInstanceMediaSilenced = ref(false); + + async function fetch(): Promise { + if (iAmAdmin) { + meta.value = await misskeyApi('admin/meta'); + } + instance.value = await misskeyApi('federation/show-instance', { + host: user.host ?? host, + }); + isInstanceBlocked.value = instance.value?.isBlocked ?? false; + isInstanceSilenced.value = instance.value?.isSilenced ?? false; + isInstanceMediaSilenced.value = instance.value?.isMediaSilenced ?? false; + } + + fetch(); + + async function toggleInstanceBlock(): Promise { + if (!iAmAdmin) return; + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + // eslint-disable-next-line no-shadow + const { host } = instance.value; + await misskeyApi('admin/update-meta', { + blockedHosts: isInstanceBlocked.value ? meta.value.blockedHosts.concat([host]) : meta.value.blockedHosts.filter(x => x !== host), + }); + } + + async function toggleInstanceSilenced(): Promise { + if (!iAmAdmin) return; + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + // eslint-disable-next-line no-shadow + const { host } = instance.value; + const silencedHosts = meta.value.silencedHosts ?? []; + await misskeyApi('admin/update-meta', { + silencedHosts: isInstanceSilenced.value ? silencedHosts.concat([host]) : silencedHosts.filter(x => x !== host), + }); + } + + async function toggleInstanceMediaSilenced(): Promise { + if (!iAmAdmin) return; + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + // eslint-disable-next-line no-shadow + const { host } = instance.value; + const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? []; + await misskeyApi('admin/update-meta', { + mediaSilencedHosts: isInstanceMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host), + }); + } + async function toggleMute() { if (user.isMuted) { os.apiWithDialog('mute/delete', { @@ -124,10 +180,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - function refreshUser() { - globalEvents.emit('refreshUser'); - } - async function getConfirmed(text: string): Promise { const confirm = await os.confirm({ type: 'warning', @@ -175,41 +227,82 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); } - let menu: MenuItem[] = [{ + watch(isInstanceBlocked, () => { + toggleInstanceBlock(); + }); + + watch(isInstanceSilenced, () => { + toggleInstanceSilenced(); + }); + + watch(isInstanceMediaSilenced, () => { + toggleInstanceMediaSilenced(); + }); + + const menuItems: MenuItem[] = []; + + menuItems.push({ icon: 'ti ti-at', text: i18n.ts.copyUsername, action: () => { copyToClipboard(`@${user.username}@${user.host ?? host}`); os.toast(i18n.ts.copied, 'copied'); }, - }, ...( 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`); os.toast(i18n.ts.copied, 'copied'); }, - }, ...(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: () => { @@ -217,97 +310,125 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter copyToClipboard(`${url}/${canonical}`); os.toast(i18n.ts.copiedLink, 'copied'); }, - }, ...($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} ` }); - }, - }, meId !== user.id ? { - type: 'link', - icon: 'ti ti-messages', - text: i18n.ts.startMessaging, - to: `/my/messaging/@${user.host === null ? user.username : user.username + '@' + user.host}`, - } : undefined, meId !== user.id && user.host === null ? { - icon: 'ti ti-users', - text: i18n.ts.inviteToGroup, - action: inviteGroup, - } : undefined, { type: 'divider' }, ...(defaultStore.state.nicknameEnabled ? [{ - icon: 'ti ti-edit', - text: i18n.ts.editNickName, + }, { + icon: 'ti ti-qrcode', + text: i18n.ts.getQRCode, action: () => { - editNickname(user); + os.displayQRCode(`https://${user.host ?? host}/@${user.username}`); }, - }] : []), { - 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); - }); - } - })); + }); - return { - type: 'switch', - text: list.name, - ref: isListed, - }; + 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} ` }); + }, + }); + + if (meId !== user.id) { + menuItems.push({ + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts.startMessaging, + to: `/my/messaging/@${user.host === null ? user.username : user.username + '@' + user.host}`, }); - }, - }, { - 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(); + + if (user.host === null) { + menuItems.push({ + icon: 'ti ti-users', + text: i18n.ts.inviteToGroup, + action: inviteGroup, + }); + } + } + + if (defaultStore.state.nicknameEnabled) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-edit', + text: i18n.ts.editNickName, + action: () => { + editNickname(user); }, - })); - }, - }] : [])] as any; + }); + } + + menuItems.push({ + icon: 'ti ti-pencil', + text: i18n.ts.editMemo, + action: editMemo, + }); + + menuItems.push({ + 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, + }; + }); + }, + }); + + menuItems.push({ + 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(); + }, + })); + }, + }); + } if ($i && meId !== user.id) { if (iAmModerator) { - menu = menu.concat([{ + menuItems.push({ type: 'parent', icon: 'ti ti-badges', text: i18n.ts.roles, @@ -345,13 +466,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, @@ -360,7 +482,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, @@ -371,7 +494,39 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter }); //} - menu = menu.concat([{ type: 'divider' }, { + menuItems.push({ type: 'divider' }); + + if (iAmAdmin && user.host !== null) { + menuItems.push({ + type: 'parent', + icon: 'ti ti-server-cog', + text: i18n.ts.instances, + children: async () => { + const federationChildMenu = [] as MenuItem[]; + + federationChildMenu.push({ + type: 'switch', + text: i18n.ts.blockThisInstance, + ref: isInstanceBlocked, + action: toggleInstanceBlock, + }, { + type: 'switch', + text: i18n.ts.silenceThisInstance, + ref: isInstanceSilenced, + action: toggleInstanceSilenced, + }, { + type: 'switch', + text: i18n.ts.mediaSilenceThisInstance, + ref: isInstanceMediaSilenced, + action: toggleInstanceMediaSilenced, + }); + + return federationChildMenu; + }, + }); + } + + menuItems.push({ icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, action: toggleMute, @@ -383,71 +538,69 @@ 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); os.toast(i18n.ts.copied, 'copied'); }, - }]); + }); } 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/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts index 04fb235694..ca48e2595a 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/scripts/hotkey.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js"; +import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; //#region types export type Keymap = Record; diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts index 459e3d166c..a1440233dd 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -11,10 +11,11 @@ import { del as idel, keys as ikeys, } 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のコマンド内のコメントアウトを外すこと @@ -39,17 +40,17 @@ 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}`); } export async function exist(key: string) { diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts index 1517e4e1e8..867ebf19ed 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 aaa4f0a86e..385f59ec39 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/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 099a22163a..78eba35ead 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 1a58b96542..be199f30f2 100644 --- a/packages/frontend/src/scripts/mfm-function-picker.ts +++ b/packages/frontend/src/scripts/mfm-function-picker.ts @@ -4,37 +4,37 @@ */ import { Ref, nextTick } from 'vue'; +import { MFM_TAGS, HTML_TAGS } from '@@/js/const.js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { MFM_TAGS, HTML_TAGS } from '@/const.js'; -import type { MenuItem } from '@/types/menu.js'; /** * MFMの装飾のリストを表示する */ export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) { - os.popupMenu([{ - text: i18n.ts.addMfmFunction, - type: 'label', - }, ...getHTMLFunctionList(textArea, textRef) - , { type: 'divider' } - , ...getMFMFunctionList(textArea, textRef)], src); + os.popupMenu([{ + text: i18n.ts.addMfmFunction, + type: 'label', + }, ...getHTMLFunctionList(textArea, textRef) + , { type: 'divider' } + , ...getMFMFunctionList(textArea, textRef)], src); } function getHTMLFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref): MenuItem[] { return HTML_TAGS.map(tag => ({ - text: tag, - icon: tag === 'bold' ? 'ti ti-bold' : tag === 'strike' ? 'ti ti-strikethrough' : tag === 'italic' ? 'ti ti-italic' : tag === 'small' ? 'ti ti-text-decrease' : tag === 'center' ? 'ti ti-align-center' : tag === 'plain' ? 'ti ti-clear-formatting' : tag === 'inlinecode' ? 'ti ti-code' : tag === 'blockcode' ? 'ti ti-script' : tag === 'mathinline' ? 'ti ti-math' : tag === 'mathblock' ? 'ti ti-math-function' : 'ti ti-icons', - action: () => add(textArea, textRef, tag), - })); + text: tag, + icon: tag === 'bold' ? 'ti ti-bold' : tag === 'strike' ? 'ti ti-strikethrough' : tag === 'italic' ? 'ti ti-italic' : tag === 'small' ? 'ti ti-text-decrease' : tag === 'center' ? 'ti ti-align-center' : tag === 'plain' ? 'ti ti-clear-formatting' : tag === 'inlinecode' ? 'ti ti-code' : tag === 'blockcode' ? 'ti ti-script' : tag === 'mathinline' ? 'ti ti-math' : tag === 'mathblock' ? 'ti ti-math-function' : 'ti ti-icons', + action: () => add(textArea, textRef, tag), + })); } function getMFMFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref): MenuItem[] { return MFM_TAGS.map(tag => ({ - text: tag, - icon: 'ti ti-icons', - action: () => add(textArea, textRef, tag), - })); + text: tag, + icon: 'ti ti-icons', + action: () => add(textArea, textRef, tag), + })); } function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref, type: string) { diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index 85d7fd49d7..114e17a2ba 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 'cherrypick-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 53b2a9e441..39c6df6500 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 1caa2dfc21..5b141222e8 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 31a9ac1ad9..11b6f52ddd 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 0000000000..f3e935a54d --- /dev/null +++ b/packages/frontend/src/scripts/reload-ask.ts @@ -0,0 +1,44 @@ +/* + * 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'; +import { defaultStore } from '@/store.js'; +import { globalEvents } from '@/events.js'; + +let isReloadConfirming = false; + +export async function reloadAsk(opts: { + unison?: boolean; + reason?: string; +}) { + if (isReloadConfirming) { + return; + } + + isReloadConfirming = true; + + if (defaultStore.state.requireRefreshBehavior === 'dialog') { + 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(); + } + } else globalEvents.emit('hasRequireRefresh', true); +} diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts deleted file mode 100644 index 6bfcef6c36..0000000000 --- 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 0edf4e9eba..0000000000 --- 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 0000000000..2c71351bf1 --- /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 'cherrypick-js'; +import type { Channels, StreamEvents, IStream, IChannelConnection } from 'cherrypick-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 9901728210..1f7ab5b9a7 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 = { @@ -68,7 +68,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 = () => { @@ -90,6 +90,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 6396c0f100..f071ddc7e7 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/scripts/upload.ts @@ -7,12 +7,13 @@ import { reactive, ref } from 'vue'; import * as Misskey from 'cherrypick-js'; import { v4 as uuid } from 'uuid'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; +import { apiUrl } from '@@/js/config.js'; import { getCompressionConfig } from './upload/compress-config.js'; import { defaultStore } from '@/store.js'; -import { apiUrl } from '@/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 0000000000..0d505fe466 --- /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/warning-external-website.ts b/packages/frontend/src/scripts/warning-external-website.ts new file mode 100644 index 0000000000..82da143be0 --- /dev/null +++ b/packages/frontend/src/scripts/warning-external-website.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { url as local } from '@@/js/config.js'; +import { instance } from '@/instance.js'; +import { defaultStore } from '@/store.js'; +import * as os from '@/os.js'; +import MkUrlWarningDialog from '@/components/MkUrlWarningDialog.vue'; + +const extractDomain = /^(https?:\/\/|\/\/)?([^@/\s]+@)?(www\.)?([^:/\s]+)/i; +const isRegExp = /^\/(.+)\/(.*)$/; + +export async function warningExternalWebsite(ev: MouseEvent, url: string) { + const domain = extractDomain.exec(url)?.[4]; + const self = !domain || url.startsWith(local); + const isTrustedByInstance = self || instance.trustedLinkUrlPatterns.some(expression => { + const r = isRegExp.exec(expression); + if (r) { + return new RegExp(r[1], r[2]).test(url); + } else if (expression.includes(' ')) return expression.split(' ').every(keyword => url.includes(keyword)); + else return domain.endsWith(expression); + }); + const isTrustedByUser = defaultStore.reactiveState.trustedDomains.value.includes(domain); + + if (!self && !isTrustedByInstance && !isTrustedByUser) { + ev.preventDefault(); + ev.stopPropagation(); + + const confirm = await new Promise<{ canceled: boolean }>(resolve => { + os.popup(MkUrlWarningDialog, { + url, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); + + if (confirm.canceled) return false; + + window.open(url, '_blank', 'noopener'); + } + + return true; +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 7daaa85c20..6773f87389 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 'cherrypick-js'; +import { hemisphere } from '@@/js/intl-const.js'; +import lightTheme from '@@/themes/l-cherrypick.json5'; +import darkTheme from '@@/themes/d-cherrypick.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, @@ -60,6 +62,8 @@ export const noteViewInterruptors: NoteViewInterruptor[] = []; export const notePostInterruptors: NotePostInterruptor[] = []; export const pageViewInterruptors: PageViewInterruptor[] = []; +const isFriendly = ref(miLocalStorage.getItem('ui') === 'friendly'); + // TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) // あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない export const defaultStore = markRaw(new Storage('base', { @@ -264,9 +268,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', @@ -320,9 +324,9 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 3, }, - emojiPickerUseDrawerForMobile: { + emojiPickerStyle: { where: 'device', - default: true, + default: 'auto' as 'auto' | 'popup' | 'drawer', }, recentlyUsedEmojis: { where: 'device', @@ -408,7 +412,7 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'vertical' as 'vertical' | 'horizontal', }, - enableCondensedLineForAcct: { + enableCondensedLine: { where: 'device', default: false, }, @@ -484,6 +488,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'deviceAccount', default: false, }, + trustedDomains: { + where: 'device', + default: [] as string[], + }, sound_masterVolume: { where: 'device', @@ -532,14 +540,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'count' as 'default' | 'count' | 'none', }, - fontSize: { - where: 'device', - default: 8, - }, - collapseDefault: { - where: 'account', - default: true, - }, requireRefreshBehavior: { where: 'device', default: 'dialog' as 'quiet' | 'dialog', @@ -548,6 +548,40 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: 'topBottom' as 'all' | 'topBottom' | 'top' | 'bottom' | 'bg' | 'hide', }, + autoLoadMoreReplies: { + where: 'device', + default: false, + }, + autoLoadMoreConversation: { + where: 'device', + default: false, + }, + useAutoTranslate: { + where: 'device', + default: false, + }, + + // - Settings/Appearance + collapseReplies: { + where: 'account', + default: false, + }, + filesGridLayoutInUserPage: { + where: 'device', + default: true, + }, + fontSize: { + where: 'device', + default: 8, + }, + collapseLongNoteContent: { + where: 'account', + default: true, + }, + collapseDefault: { + where: 'account', + default: true, + }, hideAvatarsInNote: { where: 'device', default: false, @@ -596,10 +630,80 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + showNoAltTextWarning: { + where: 'device', + default: true, + }, + alwaysShowCw: { + where: 'device', + default: false, + }, + showReplyTargetNoteInSemiTransparent: { + where: 'device', + default: true, + }, nsfwOpenBehavior: { where: 'device', default: 'click' as 'click' | 'doubleClick', }, + showReplyButtonInNoteFooter: { + where: 'device', + default: true, + }, + showRenoteButtonInNoteFooter: { + where: 'device', + default: true, + }, + showLikeButtonInNoteFooter: { + where: 'device', + default: true, + }, + showDoReactionButtonInNoteFooter: { + where: 'device', + default: true, + }, + showQuoteButtonInNoteFooter: { + where: 'device', + default: true, + }, + showMoreButtonInNoteFooter: { + where: 'device', + default: true, + }, + + // - Settings/Navigation bar + showMenuButtonInNavbar: { + where: 'device', + default: !isFriendly.value, + }, + showHomeButtonInNavbar: { + where: 'device', + default: true, + }, + showExploreButtonInNavbar: { + where: 'device', + default: isFriendly.value, + }, + showSearchButtonInNavbar: { + where: 'device', + default: false, + }, + showNotificationButtonInNavbar: { + where: 'device', + default: true, + }, + showMessageButtonInNavbar: { + where: 'device', + default: isFriendly.value, + }, + showWidgetButtonInNavbar: { + where: 'device', + default: true, + }, + showPostButtonInNavbar: { + where: 'device', + default: true, + }, // - Settings/Timeline enableHomeTimeline: { @@ -710,23 +814,21 @@ export const defaultStore = markRaw(new Storage('base', { where: 'account', default: false, }, - enableLongPressOpenAccountMenu: { + enableWidgetsArea: { where: 'device', default: true, }, - friendlyShowAvatarDecorationsInNavBtn: { + friendlyUiEnableNotificationsArea: { where: 'device', - default: false, + default: true, }, - - // - etc - friendlyEnableNotifications: { + enableLongPressOpenAccountMenu: { where: 'device', default: true, }, - friendlyEnableWidgets: { + friendlyUiShowAvatarDecorationsInNavBtn: { where: 'device', - default: true, + default: false, }, // #endregion })); @@ -758,8 +860,6 @@ interface Watcher { /** * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) */ -import lightTheme from '@/themes/l-cherrypick.json5'; -import darkTheme from '@/themes/d-cherrypick.json5'; export class ColdDeviceStorage { public static default = { @@ -796,7 +896,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 b6f2af9feb..44faff073f 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -5,19 +5,22 @@ import * as Misskey from 'cherrypick-js'; import { markRaw } from 'vue'; +import { wsOrigin } from '@@/js/config.js'; import { $i } from '@/account.js'; -import { wsOrigin } from '@/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 612c5817e7..613515af72 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -26,24 +26,17 @@ $code-monospace-font: "JetBrains Mono", "Pretendard JP", Pretendard, Consolas, M --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; --cherry: rgb(255, 188, 220); --pick: rgb(177, 211, 255); --misskey: rgb(134, 179, 0); --cast: rgb(181, 151, 246); --ella: rgb(150, 198, 234); + + @media (max-width: 500px) { + --margin: var(--marginHalf); + } } ::selection { @@ -340,11 +333,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)); } } @@ -354,11 +347,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))); } } @@ -471,6 +464,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; @@ -521,12 +524,12 @@ rt { } ._monospace { - font-family: $monospace-font + font-family: $monospace-font; } code[class*="language-"], pre[class*="language-"] { - font-family: $code-monospace-font + font-family: $code-monospace-font; } ._zoom { @@ -545,7 +548,7 @@ pre[class*="language-"] { --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 e86c735102..2945d8ff7b 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -4,10 +4,10 @@ */ import { defineAsyncComponent } from 'vue'; +import { host } from '@@/js/config.js'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { instance } from '@/instance.js'; -import { host } from '@/config.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; @@ -51,7 +51,9 @@ function toolsMenuItems(): MenuItem[] { } export function openInstanceMenu(ev: MouseEvent) { - os.popupMenu([{ + const menuItems: MenuItem[] = []; + + menuItems.push({ text: instance.name ?? host, type: 'label', }, { @@ -79,12 +81,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', @@ -94,53 +102,85 @@ 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' }, { - type: 'parent', - text: i18n.ts.document, - icon: 'ti ti-bulb', - children: [{ + }); + + if (instance.impressumUrl) { + menuItems.push({ type: 'a', - text: i18n.ts.document, - icon: 'ti ti-bulb', - href: 'https://misskey-hub.net/docs/for-users/', + text: i18n.ts.impressum, + icon: 'ti ti-file-invoice', + href: instance.impressumUrl, target: '_blank', - }, { - type: 'link', - text: i18n.ts._mfm.cheatSheet, - icon: 'ti ti-help-circle', - to: '/mfm-cheat-sheet', - }], - }, ($i) ? { - text: i18n.ts._initialTutorial.launchTutorial, - icon: 'ti ti-presentation', - action: () => { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTutorialDialog.vue')), {}, { - closed: () => dispose(), + }); + } + + 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: 'parent', + icon: 'ti ti-bulb', + text: i18n.ts.document, + children: async () => { + const documentChildMenu = [] as MenuItem[]; + + documentChildMenu.push({ + type: 'a', + text: i18n.ts.document, + icon: 'ti ti-bulb', + href: 'https://misskey-hub.net/docs/for-users/', + target: '_blank', + }, { + type: 'link', + text: i18n.ts._cfm.cheatSheet, + icon: 'ti ti-help-circle', + to: '/cfm-cheat-sheet', }); + + return documentChildMenu; }, - } : 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 ee746aeca2..3ae1a1f0e6 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -43,9 +43,9 @@ SPDX-License-Identifier: AGPL-3.0-only -
DEV BUILD
+
DEV BUILD
-
{{ i18n.ts.loggedInAsBot }}
+
{{ i18n.ts.loggedInAsBot }}
diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index 07648483db..a9d6c42377 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -27,12 +27,12 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 6894a31886..54fb904694 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -19,11 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only