Compare commits

..

30 Commits

Author SHA1 Message Date
github-actions[bot] c70fb20b9e Bump version to 2026.7.0-beta.6 2026-07-25 01:29:48 +00:00
renovate[bot] cde2fe24c4 fix(deps): update dependency tar to v7.5.21 [security] (#17798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 10:27:21 +09:00
renovate[bot] e8951b7aa1 fix(deps): update dependency @fastify/static to v10.1.2 [security] (#17796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 09:27:15 +09:00
renovate[bot] d2973ecdca chore(deps): update [github actions] update dependencies (major) (#17792)
chore(deps): update [github actions] update dependencies

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 22:46:54 +09:00
renovate[bot] cf7a6b6efc chore(deps): update [github actions] update dependencies (#17791)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 21:40:01 +09:00
syuilo 7c29a18d61 enhance(frontend): アップロード前のファイルのプレビューに対応&アップロード後のプレビューにMkLightboxを使用 (#17794)
* wip

* [ci skip] Update CHANGELOG.md

* Update MkUploaderItems.vue

* Update MkUploaderItems.vue

* Update MkUploaderItems.vue
2026-07-24 21:39:28 +09:00
syuilo 16ca5a72a6 New Crowdin updates (#17733)
* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (English)

[ci skip]

* New translations ja-jp.yml (Korean)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Italian)

[ci skip]

* New translations ja-jp.yml (Italian)

[ci skip]

* New translations ja-jp.yml (Italian)

[ci skip]

* New translations ja-jp.yml (Catalan)

[ci skip]

* New translations ja-jp.yml (Chinese Simplified)

[ci skip]

* New translations ja-jp.yml (Spanish)

[ci skip]
2026-07-24 11:26:05 +09:00
syuilo db8ee16e03 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-24 10:06:57 +09:00
syuilo 521f6006fe add note 2026-07-24 10:06:56 +09:00
かっこかり 19fa9a24ab revert(frontend): "タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように" (#17787)
Revert "perf(frontend): タイムラインのリアルタイム更新のアニメーションでTransitionGroupを使用しないように (#17708)"

This reverts commit 152e0fc48c.
2026-07-24 09:56:39 +09:00
github-actions[bot] f68da80ebd Bump version to 2026.7.0-beta.5 2026-07-24 00:37:34 +00:00
かっこかり fcb4ced33d enhance(frontend): tagcanvas実装を変更 (#17753)
* enhance(frontend): tagcanvas実装を変更

* fix indent

* fix

* fix

* review fixes
2026-07-24 09:30:35 +09:00
おさむのひと 85e810533b enhance: 旧Loggerを拡張して構造化ログを直接受け取れるように (#17780)
* enhance: 旧Loggerを拡張して構造化ログを直接受け取れるように

* fix lint
2026-07-24 09:28:56 +09:00
かっこかり 661838c89e fix(backend): 初期設定で作成したアカウント以外でアカウント作成APIが利用できない問題を修正 (#17783)
* fix(backend): 初期設定で作成したアカウント以外でアカウント作成APIが利用できない問題を修正

* Update Changelog

* fix

* fix
2026-07-24 09:27:40 +09:00
かっこかり 80d14231c5 fix(frontend): Lightbox上で強くスワイプしたときに一時的に画面が操作不能になる問題を修正 (#17785)
* fix(frontend): Lightbox上で強くスワイプしたときに一時的に画面が操作不能になる問題を修正

* fix

* update comment
2026-07-23 21:55:20 +09:00
syuilo 28153f7b5f Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-07-23 21:46:20 +09:00
syuilo 6d81c81ab2 chore(gh): fix tests 2026-07-23 21:46:19 +09:00
github-actions[bot] 6f54416f63 Bump version to 2026.7.0-beta.4 2026-07-23 12:33:02 +00:00
かっこかり a63c8ef939 fix(frontend): センシティブなメディアの表示が常にぼかすになっている場合に初回表示した画像がぼかされる問題を修正 (#17781) 2026-07-23 20:03:10 +09:00
かっこかり 015868f17f 【テスト中】fix(frontend): 画像の上をタップした状態でPull to Refreshを行うと画像も開かれてしまう問題を修正 (#17784)
* fix(frontend): 画像の上をタップした状態でPull to Refreshを行うと画像も開かれてしまう問題を修正

* attempt to fix

* revert

* revert
2026-07-23 20:01:33 +09:00
かっこかり dc3fd42d63 fix(frontend): follow-up of #17775 2026-07-23 17:27:14 +09:00
かっこかり 34a4b9b74c Update .coderabbit.yaml [ci skip] 2026-07-23 17:19:19 +09:00
syuilo 4a7ff6d7f4 chore(gh): use nbsp for table 2026-07-23 14:58:08 +09:00
syuilo 3d87032881 chore(gh): use nbsp for table
狭い画面だと変な場所で自動改行されて崩れるため
2026-07-23 11:10:30 +09:00
github-actions[bot] dc3f2a0012 Bump version to 2026.7.0-beta.3 2026-07-22 13:33:07 +00:00
かっこかり 79b4a73679 fix(frontend): いくつかのイベントリスナーの解除処理の修正 (#17768)
* fix(frontend): いくつかのイベントリスナーの解除処理の修正

* fix
2026-07-22 22:28:01 +09:00
かっこかり 170ee2bfeb perf(frontend): タブがバックグラウンドな間のsetIntervalを停止するように (#17769) 2026-07-22 22:24:14 +09:00
かっこかり c9ac4f9b41 fix(frontend): MkNoteDetailedで削除された引用元が表示されない問題を修正 (#17776)
* fix(frontend): MkNoteDetailedで削除された引用元が表示されない問題を修正

* Update Changelog

* fix
2026-07-22 22:18:01 +09:00
おさむのひと d5a09b3919 enhance: APIアクセスログを出力可能なように(opt-in) (#17771)
* enhance: APIアクセスログを出力可能なように(opt-in)

* fix
2026-07-22 22:17:29 +09:00
かっこかり 5cb6661484 fix(frontend): StreamingTimelineのインスタンスが保持された状態でページを行き来するとアニメーションが再度再生される問題を修正 (#17775)
* fix(frontend): StreamingTimelineのインスタンスが保持された状態でページを行き来するとアニメーションが再度再生される問題を修正

* fix
2026-07-22 21:13:27 +09:00
112 changed files with 1875 additions and 712 deletions
+6
View File
@@ -20,9 +20,15 @@ reviews:
instructions: |
【レビュー対象外】
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。
【特別な対応を要するレビュー】
- プルリクエストで変更されていない部分において、プルリクエストのタイトルや説明文に照らしてスコープ外であるが修正すべき点を見つけた場合は、このプルリクエストで修正させるのではなく、別のプルリクエストを開いてそれを修正するように指示してください。\
なお、当該プルリクエストで行が追加/削除されたことによりスコープ外の問題が起こりうる場合は、例外的にこのプルリクエストで修正させてください。
【注力してほしい観点】
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
- プルリクエストのタイトルや説明文に照らしてスコープ外の変更が含まれていないかをレビューしてください。なお、明らかにスコープ外であると確信できるもの(例: 全く関係ないファイルを編集している等)のみを対象とし、スコープ外かどうかの判断に困る場合は指摘しないでください。\
スコープ外の変更が含まれていた場合は指摘し、その部分について、このプルリクエストに含めずに別のプルリクエストを開いて変更するように指示してください。
path_filters:
- "!CHANGELOG.md"
- "!**/__snapshots__/**"
+14 -1
View File
@@ -241,7 +241,7 @@ proxyBypassHosts:
# Log settings
# logging:
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
# # Log output format. "json" outputs one-line JSON; defaults to "pretty".
# format: pretty
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
# level: info
@@ -251,6 +251,19 @@ proxyBypassHosts:
# queue: error
# queue.deliver: debug
# db.sql: off
# # HTTP status classes to record in the access log. No output when unspecified.
# access:
# statusClasses:
# - '2xx'
# - '3xx'
# - '4xx'
# - '5xx'
# # Enable body logging explicitly only during development.
# bodies:
# request: false
# response: false
# # 16 KiB by default, up to 128 KiB.
# maxBytes: 16384
# sql:
# # Outputs query parameters during SQL execution to the log.
# # default: false
+14 -1
View File
@@ -474,7 +474,7 @@ proxyBypassHosts:
# Log settings
# logging:
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
# # Log output format. "json" outputs one-line JSON; defaults to "pretty".
# format: pretty
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
# level: info
@@ -484,6 +484,19 @@ proxyBypassHosts:
# queue: error
# queue.deliver: debug
# db.sql: off
# # HTTP status classes to record in the access log. No output when unspecified.
# access:
# statusClasses:
# - '2xx'
# - '3xx'
# - '4xx'
# - '5xx'
# # Enable body logging explicitly only during development.
# bodies:
# request: false
# response: false
# # 16 KiB by default, up to 128 KiB.
# maxBytes: 16384
# sql:
# # Outputs query parameters during SQL execution to the log.
# # default: false
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -17,12 +17,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -35,13 +35,13 @@ jobs:
steps:
- name: Checkout base
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
ref: ${{ github.base_ref }}
path: base
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
ref: refs/pull/${{ github.event.number }}/merge
path: head
@@ -51,7 +51,7 @@ jobs:
with:
package_json_file: head/package.json
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: 'head/.node-version'
cache: 'pnpm'
+2 -2
View File
@@ -12,13 +12,13 @@ jobs:
steps:
- name: Checkout head
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: setup pnpm
uses: pnpm/action-setup@v6
- name: setup node
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: pnpm
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
submodules: true
persist-credentials: false
@@ -29,7 +29,7 @@ jobs:
- name: setup node
id: setup-node
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: pnpm
@@ -66,7 +66,7 @@ jobs:
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
steps:
- name: checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
submodules: true
persist-credentials: false
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Check version
run: |
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Check
run: |
counter=0
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
check_copyright_year:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
- run: |
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
echo "Please change copyright year!"
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Check out the repo
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Docker meta
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
DOCKLE_VERSION: 0.4.15
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
run: |
@@ -56,7 +56,7 @@ jobs:
steps:
- name: Checkout base
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
persist-credentials: false
repository: ${{ github.event.pull_request.base.repo.full_name }}
@@ -65,7 +65,7 @@ jobs:
submodules: true
- name: Checkout head
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
with:
persist-credentials: false
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -79,7 +79,7 @@ jobs:
package_json_file: head/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: head/.node-version
cache: pnpm
+2 -2
View File
@@ -25,14 +25,14 @@ jobs:
ref: refs/pull/${{ github.event.number }}/merge
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
ref: ${{ matrix.ref }}
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@v7
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
+9 -9
View File
@@ -40,13 +40,13 @@ jobs:
pnpm_install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -73,19 +73,19 @@ jobs:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- name: Restore eslint cache
uses: actions/cache@v5.0.5
uses: actions/cache@v6.1.0
with:
path: ${{ env.eslint-cache-path }}
key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
@@ -104,13 +104,13 @@ jobs:
- sw
- misskey-js
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -123,13 +123,13 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 0
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: ".node-version"
cache: "pnpm"
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+2 -2
View File
@@ -36,12 +36,12 @@ jobs:
- diagnostics-frontend
- changelog-checker
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- uses: actions/setup-node@v6.4.0
- uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
edit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PR
run: |
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
outputs:
pr_number: ${{ steps.get_pr.outputs.pr_number }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
- name: Get PRs
run: |
+3 -3
View File
@@ -22,12 +22,12 @@ jobs:
NODE_OPTIONS: "--max_old_space_size=7168"
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
if: github.event_name != 'pull_request_target'
with:
fetch-depth: 0
submodules: true
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
if: github.event_name == 'pull_request_target'
with:
fetch-depth: 0
@@ -39,7 +39,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+9 -9
View File
@@ -43,7 +43,7 @@ jobs:
ports:
- 56312:6379
meilisearch:
image: getmeili/meilisearch:v1.48.1
image: getmeili/meilisearch:v1.49.0
ports:
- 57712:7700
env:
@@ -51,7 +51,7 @@ jobs:
MEILI_ENV: development
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
@@ -60,7 +60,7 @@ jobs:
run: |
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
@@ -74,7 +74,7 @@ jobs:
- name: Test
run: pnpm --filter backend test-and-coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/backend/coverage/coverage-final.json
@@ -103,13 +103,13 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
@@ -123,7 +123,7 @@ jobs:
- name: Test
run: pnpm --filter backend test-and-coverage:e2e
- name: Upload to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/backend/coverage/coverage-final.json
@@ -147,7 +147,7 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
@@ -156,7 +156,7 @@ jobs:
id: current-date
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
- .node-version
- .github/min.node-version
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: true
- name: Setup pnpm
@@ -35,7 +35,7 @@ jobs:
run: |
sudo apt install -y ffmpeg
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: ${{ matrix.node-version-file }}
cache: 'pnpm'
+5 -5
View File
@@ -28,13 +28,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -48,7 +48,7 @@ jobs:
- name: Test
run: pnpm --filter frontend test-and-coverage
- name: Upload Coverage
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/frontend/coverage/coverage-final.json
@@ -71,13 +71,13 @@ jobs:
- 56312:6379
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+3 -3
View File
@@ -22,13 +22,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
uses: actions/checkout@v7.0.0
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
@@ -48,7 +48,7 @@ jobs:
CI: true
- name: Upload Coverage
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/misskey-js/coverage/coverage-final.json
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+2 -2
View File
@@ -17,13 +17,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/checkout@v7.0.0
with:
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
uses: actions/setup-node@v6.4.0
uses: actions/setup-node@v7.0.0
with:
node-version-file: '.node-version'
cache: 'pnpm'
+7 -3
View File
@@ -21,7 +21,7 @@
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- Enhance: 画像ビューワーを刷新・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
@@ -29,7 +29,8 @@
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -40,6 +41,7 @@
- Fix: チャートの描画終了後にリソースが解放されない問題を修正
- Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正
- Fix: QRコードリーダーがページを離れても停止しない問題を修正
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
### Server
- Feat: OpenTelemetryサポート
@@ -51,11 +53,12 @@
- 全ての受信HTTPリクエスト
- 全ての送信HTTPリクエスト
- ジョブキュー(エンキュー元のトレースを含む)
- Feat: ログ基盤の刷新(部分的に導入中)
- Feat: ログ基盤の刷新
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
- バックエンドのログを1行JSON形式で出力できるように
- OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、OpenTelemetryのTrace Contextにも対応)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように
@@ -67,6 +70,7 @@
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
## 2026.6.0
+15
View File
@@ -619,6 +619,8 @@ output: "Sortida"
script: "Script"
disablePagesScript: "Desactivar AiScript a les pàgines "
updateRemoteUser: "Actualitzar la informació de l'usuari remot"
unsetMfa: "Desactiva l'autenticació de dos factors"
unsetMfaConfirm: "Voldries desactivar l'autenticació de dos factors?"
unsetUserAvatar: "Desactiva l'avatar "
unsetUserAvatarConfirm: "Segur que vols desactivar l'avatar?"
unsetUserBanner: "Desactiva el bàner "
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Afegeix al calaix d'emojis"
emojiPaletteAlreadyAddedConfirm: "Aquest emoji ja està inclòs en aquest calaix d'emojis. Vols afegir-lo de nou?"
append: "Afegeix al final"
prepend: "Afegeix al principi"
urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures"
urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà."
_imageEditing:
_vars:
caption: "Títol de l'arxiu"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "Els resultats de la detecció interna seran desats, inclòs si aquesta opció es troba desactivada."
analyzeVideos: "Activar anàlisis de vídeos "
analyzeVideosDescription: "Analitzar els vídeos a més de les imatges. Això incrementarà lleugerament la càrrega del servidor."
externalServiceInfo: "La detecció de mitjans sensibles s'ha delegat a un servei extern (sensitive-detector). Per utilitzar aquesta funció, cal configurar un servei sidecar separat i establir els detalls de connexió que es detallen a continuació. Si no es configuren els detalls de connexió, no es durà a terme cap detecció (el contingut es tractarà com a no sensible)."
apiUrl: "URL per connectar-se al servei de verificació"
apiUrlDescription: "L'URL base del servei de detector de sensibilitat (per exemple, http://localhost:3009). Si us connecteu a un servei en una xarxa privada, afegiu la xarxa de destinació a la configuració `allowedPrivateNetworks` del fitxer de configuració. Si utilitzeu un proxy, configureu també `proxyBypassHosts`. Si es deixa en blanc, no es realitzaran comprovacions de sensibilitat."
apiKey: "Clau de l'API"
apiKeyDescription: "Introduïu això si l'autenticació (token Bearer) està configurada al costat del servei d'autenticació. Si no està configurada, deixeu aquest camp en blanc."
timeout: "Temps d'espera (mil·lisegons)"
timeoutDescription: "1 Aquesta és la durada del temps mort per sol·licitud de validació."
maxImagesPerRequest: "1 Nombre màxim d'imatges per sol·licitud"
maxImagesPerRequestDescription: "1 Quan s'analitzen múltiples fotogrames, com en un vídeo, aquest és el nombre màxim d'imatges que es poden enviar en una única petició. Qualsevol imatge que superi aquest límit es dividirà en parts i s'enviarà seqüencialment. Assegureu-vos que aquest ajust no superi el valor de `maxParts` al costat del `sensitive-detector` (per defecte: 10). Si es supera aquest límit, totes les imatges d'aquest lot es consideraran no sensibles."
_emailUnavailable:
used: "Aquest correu electrònic ja s'està fent servir"
format: "El format del correu electrònic és invàlid "
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "Veure registre de moderació "
"read:admin:show-user": "Veure informació privada de l'usuari "
"write:admin:suspend-user": "Suspendre usuari"
"write:admin:unset-mfa": "Desactiva l'autenticació de dos factors per a un usuari"
"write:admin:unset-user-avatar": "Esborrar avatar d'usuari "
"write:admin:unset-user-banner": "Esborrar bàner de l'usuari "
"write:admin:unsuspend-user": "Treure la suspensió d'un usuari"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Decoració de l'avatar creada"
updateAvatarDecoration: "S'ha actualitzat la decoració de l'avatar "
deleteAvatarDecoration: "S'ha esborrat la decoració de l'avatar "
unsetMfa: "Desactiva l'autenticació de dos factors per a l'usuari"
unsetUserAvatar: "Esborrar l'avatar d'aquest usuari"
unsetUserBanner: "Esborrar el bàner d'aquest usuari"
createSystemWebhook: "Crear un SystemWebhook"
+15
View File
@@ -619,6 +619,8 @@ output: "Output"
script: "Script"
disablePagesScript: "Disable AiScript on Pages"
updateRemoteUser: "Update remote user information"
unsetMfa: "Reset two-factor authentication"
unsetMfaConfirm: "Are you sure you want to reset two-factor authentication?"
unsetUserAvatar: "Unset avatar"
unsetUserAvatarConfirm: "Are you sure you want to unset the avatar?"
unsetUserBanner: "Unset banner"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Add to emoji palette"
emojiPaletteAlreadyAddedConfirm: "This emoji is already included in this emoji palette. Do you want to add it again?"
append: "Append to end"
prepend: "Append to beginning"
urlPreviewSensitiveList: "URL to restrict thumbnail display"
urlPreviewSensitiveListDescription: "Use spaces to specify AND conditions, and line breaks to specify OR conditions. Enclose text in slashes to use regular expressions. If a match is found, the thumbnail will be hidden."
_imageEditing:
_vars:
caption: "File caption"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "The results of the internal detection will be retained even if this option is turned off."
analyzeVideos: "Enable analysis of videos"
analyzeVideosDescription: "Analyzes videos in addition to images. This will slightly increase the load on the server."
externalServiceInfo: "The detection of sensitive media has been offloaded to an external service (sensitive-detector). To use this feature, you must set up a separate service and configure the connection details provided below. If no connection details are configured, no detection will be performed (it will be treated as non-sensitive)."
apiUrl: "Detection service endpoint URL"
apiUrlDescription: "The base URL for the sensitive-detector service (e.g., http://localhost:3009). If you are connecting to a service on a private network, please allow the target network in the allowedPrivateNetworks setting in the configuration file. If you are using a proxy, please also configure proxyBypassHosts. If left blank, sensitive media detection will not be performed."
apiKey: "API key"
apiKeyDescription: "Enter this if authentication (Bearer token) is configured on the detector service. If it is not configured, please leave it blank."
timeout: "Timeout (Milliseconds)"
timeoutDescription: "Timeout duration for each judgment request."
maxImagesPerRequest: "Max images per request"
maxImagesPerRequestDescription: "Maximum number of images that can be sent in a single request when processing multi-frame at once, such as videos. Any images exceeding this limit will be split and sent sequentially. Please ensure this is set to not exceed the maxParts setting (default: 10) on the detector service. If it exceeds this limit, all items in that chunk will be treated as non-sensitive."
_emailUnavailable:
used: "This email address is already being used"
format: "The format of this email address is invalid"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "View moderation log"
"read:admin:show-user": "View private user info"
"write:admin:suspend-user": "Suspend user"
"write:admin:unset-mfa": "Reset two-factor authentication for the user"
"write:admin:unset-user-avatar": "Remove user avatar"
"write:admin:unset-user-banner": "Remove user banner"
"write:admin:unsuspend-user": "Unsuspend user"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Avatar decoration created"
updateAvatarDecoration: "Avatar decoration updated"
deleteAvatarDecoration: "Avatar decoration deleted"
unsetMfa: "Reset two-factor authentication for the user"
unsetUserAvatar: "User avatar unset"
unsetUserBanner: "User banner unset"
createSystemWebhook: "System Webhook created"
+15
View File
@@ -619,6 +619,8 @@ output: "Salida"
script: "Script"
disablePagesScript: "Deshabilitar AiScript en Páginas"
updateRemoteUser: "Actualizar información de usuario remoto"
unsetMfa: "Desactivar la autenticación de dos factores"
unsetMfaConfirm: "¿Desea desactivar la autenticación de dos factores?"
unsetUserAvatar: "Quitar avatar"
unsetUserAvatarConfirm: "¿Confirmas que quieres quitar tu avatar?"
unsetUserBanner: "Quitar banner"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Añadir a la paleta de emojis"
emojiPaletteAlreadyAddedConfirm: "Este emoji ya está incluido en esta paleta de emojis. ¿Quieres volver a añadirlo?"
append: "Añadir al final"
prepend: "Añadir al principio"
urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas"
urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura."
_imageEditing:
_vars:
caption: "Título del archivo"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "Los resultados de la detección interna pueden ser retenidos incluso si la opción está desactivada."
analyzeVideos: "Habilitar el análisis de videos"
analyzeVideosDescription: "Analizar videos en adición a las imágenes. Esto puede incrementar ligeramente la carga del servidor."
externalServiceInfo: "La detección de contenidos sensibles se ha externalizado a un servicio externo (sensitive-detector). Para utilizar esta función, es necesario configurar por separado un servicio «sidecar» y establecer los datos de conexión que se indican a continuación. Si no se configuran los datos de conexión, no se realizará la detección (se tratará como contenido no sensible)."
apiUrl: "URL de conexión al servicio de verificación"
apiUrlDescription: "URL base del servicio «sensitive-detector» (por ejemplo: http://localhost:3009). Si te conectas a un servicio situado en una red privada, debes permitir la red de destino en el parámetro «allowedPrivateNetworks» del archivo de configuración. Si utilizas un proxy, configura también el parámetro «proxyBypassHosts». Si se deja en blanco, no se realizará la evaluación de datos sensibles."
apiKey: "Clave API"
apiKeyDescription: "Introduce este dato si se ha configurado la autenticación (token Bearer) en el servicio de validación. Si no se ha configurado, déjalo en blanco."
timeout: "Tiempo de espera (milisegundos)"
timeoutDescription: "Duración del tiempo de espera para cada solicitud de resolución."
maxImagesPerRequest: "Número máximo de imágenes por solicitud"
maxImagesPerRequestDescription: "Es el número máximo de imágenes que se pueden enviar en una sola solicitud al analizar varios fotogramas, como en un vídeo. Las imágenes que superen este límite se dividirán y se enviarán de forma secuencial. Configúralo de manera que no se supere el valor de «maxParts» de sensitive-detector (por defecto: 10). Si se supera este límite, todas las imágenes de ese fragmento se considerarán no sensibles."
_emailUnavailable:
used: "Ya fue usado"
format: "Formato no válido."
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "Ver log de moderación"
"read:admin:show-user": "Ver información privada de usuario"
"write:admin:suspend-user": "Suspender cuentas de usuario"
"write:admin:unset-mfa": "Desactivar la autenticación de dos factores del usuario"
"write:admin:unset-user-avatar": "Quitar avatares de usuario"
"write:admin:unset-user-banner": "Quitar banner de usuarios"
"write:admin:unsuspend-user": "Quitar suspensión de cuentas de usuario"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Decoración de avatar creada"
updateAvatarDecoration: "Decoración de avatar actualizada"
deleteAvatarDecoration: "Decoración de avatar eliminada"
unsetMfa: "Desactivar la autenticación de dos factores del usuario"
unsetUserAvatar: "Quitar decoración de avatar de este usuario"
unsetUserBanner: "Quitar banner de este usuario"
createSystemWebhook: "Crear un SystemWebhook"
+15
View File
@@ -619,6 +619,8 @@ output: "Output"
script: "Script"
disablePagesScript: "Disabilitare AiScript nelle pagine"
updateRemoteUser: "Aggiorna dati dal profilo remoto"
unsetMfa: "Rimuovere l'autenticazione a due fattori (2FA/MFA)"
unsetMfaConfirm: "Vuoi davvero rimuovere l'autenticazione a due fattori?"
unsetUserAvatar: "Rimozione foto profilo"
unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?"
unsetUserBanner: "Rimuovi intestazione profilo"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Aggiungi alla tavolozza emoji"
emojiPaletteAlreadyAddedConfirm: "Questa emoji è già inclusa in nella tavolozza. Vuoi davvero aggiungerla?"
append: "Accodare"
prepend: "Anteporre"
urlPreviewSensitiveList: "URL da impedire alla vista delle anteprime"
urlPreviewSensitiveListDescription: "Separando con uno spazio si indica E, separando con una linea si indica O. Circondando con barre / si indica una Espressione Regolare.\nLe URL che coincidono con le indicazioni non verranno visualizzate."
_imageEditing:
_vars:
caption: "Didascalia dell'immagine"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "Anche se questa impostazione è disattivata, il risultato della decisione viene conservato internamente."
analyzeVideos: "Abilitazione dell'analisi video."
analyzeVideosDescription: "Assicuratevi che vengano analizzati anche i video oltre alle immagini fisse. Il carico del server aumenterà leggermente."
externalServiceInfo: "Abbiamo spostato esternamente il riconoscimento di media espliciti (sensitive-detector). Per usufruirne devi impostare un servizio separato e indicare di seguito la destinazione. Se non è impostata, non viene emesso alcun giudizio (contenuto NON esplicito)."
apiUrl: "URL di connessione a Sensitive-Detector"
apiUrlDescription: "L'URL di base del servizio (ad esempio http://localhost:3009). Collegandosi a una rete privata, autorizzare la rete nel parametro allowedPrivateNetworks nel file di configurazione.\nCollegandosi con un proxy, è anche necessario impostare proxyBypassHosts. Nel caso il campo sia vuoto, non vengono emessi giudizi di sensibilità."
apiKey: "Chiave API"
apiKeyDescription: "Indicare il token di autenticazione (Bearer token), soltanto se occorre, altrimenti lasciare vuoto."
timeout: "Timeout (ms)"
timeoutDescription: "Tempo limite per ogni richiesta"
maxImagesPerRequest: "Numero massimo di media per ogni richiesta"
maxImagesPerRequestDescription: "In caso ci siano più fotogrammi, come nei video, questo è il numero massimo di immagini da inviare in una richiesta. Oltre questo numero, il totale sarà diviso e inviato in sequenza.\nImpostare in modo che non superi il valore maxParts (default: 10) in Sensitive-Detector. Se viene superato, il media sarà considerato non esplicito."
_emailUnavailable:
used: "Email già in uso"
format: "Formato email non valido"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "Vedere lo storico di moderazione"
"read:admin:show-user": "Vedere le informazioni private dei profili"
"write:admin:suspend-user": "Sospendere i profili"
"write:admin:unset-mfa": "Può rimuovere l'autenticazione a due fattori (2FA/MFA)"
"write:admin:unset-user-avatar": "Rimuovere la foto profilo dai profili"
"write:admin:unset-user-banner": "Rimuovere l'immagine testata dai profili"
"write:admin:unsuspend-user": "Rimuovere la sospensione ai profili"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "Crea una decorazione della foto profilo"
updateAvatarDecoration: "Modifica una decorazione della foto profilo"
deleteAvatarDecoration: "Elimina una decorazione della foto profilo"
unsetMfa: "Rimossa l'autenticazione a due fattori (2FA7MFA)"
unsetUserAvatar: "Toglie una foto profilo"
unsetUserBanner: "Toglie una immagine di intestazione profilo"
createSystemWebhook: "Aggiunge un System Webhook"
+15
View File
@@ -619,6 +619,8 @@ output: "출력"
script: "스크립트"
disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음"
updateRemoteUser: "리모트 유저 정보 갱신"
unsetMfa: "2단계 인증 해제"
unsetMfaConfirm: "2단계 인증을 해제하시겠습니까?"
unsetUserAvatar: "아바타 제거"
unsetUserAvatarConfirm: "아바타를 제거할까요?"
unsetUserBanner: "배너 제거"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "이모지 팔레트에 추가"
emojiPaletteAlreadyAddedConfirm: "이 이모지는 이미 이 이모지 팔레트에 포함돼있습니다. 다시 추가하시겠습니까?"
append: "맨뒤에 추가"
prepend: "맨앞에 추가"
urlPreviewSensitiveList: "썸네일 표시 제한 URL"
urlPreviewSensitiveListDescription: "공백으로 구분하면 AND 지정으로 되고, 줄내림으로 구분하면 OR 지정으로 됩니다. 슬래시로 감싸면 정규 표현으로 됩니다. 일치한 경우에 썸네일이 표시되지 않게 됩니다."
_imageEditing:
_vars:
caption: "파일 설명"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지됩니다."
analyzeVideos: "동영상도 같이 확인하기"
analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지합니다. 서버의 부하를 약간 증가시킵니다."
externalServiceInfo: "민감한 미디어 판정은 외부 서비스(sensitive-detector)로 분리됐습니다. 이 기능을 이용하려면 별도 사이드카 서비스를 설정하고, 아래의 접속 위치를 설정해야 합니다. 접속 위치가 설정되지 않은 경우에는 판정이 이루어지지 않습니다. (민감하지 않음 처리)"
apiUrl: "판정 서비스의 접속 위치 URL"
apiUrlDescription: "sensitive-detector 서비스의 베이스 URL(예시: http://localhost:3009). 프라이빗 네트워크상의 서비스에 접속하는 경우에는 설정 파일의 allowedPrivateNetworks로 접속 위치 네트워크를 허가해 주십시오. 프록시를 사용하고 있는 경우에는 proxyBypassHosts도 설정해 주십시오. 비어있으면 민감함 판정은 이루어지지 않습니다."
apiKey: "API 키"
apiKeyDescription: "판정 서비스 측에서 인증(Bearer 토큰)을 설정하고 있는 경우에 입력합니다. 설정하고 있지 않은 경우에는 빈칸으로 둬주십시오."
timeout: "타임아웃 (밀리초)"
timeoutDescription: "판정 요청 1회당 타임아웃 시간입니다."
maxImagesPerRequest: "한 요청당 최대 이미지 수"
maxImagesPerRequestDescription: "동영상 등 여러 프레임을 판정할 때, 한 번의 요청에 모아서 보내는 이미지의 최대 장수입니다. 이를 넘으면 분할해 순차적으로 송신됩니다. sensitive-detector 측의 maxParts 설정(기본: 10)을 남지 않도록 설정해 주십시오. 넘은 경우에는 그 청크는 전부 민감하지 않음 처리로 됩니다."
_emailUnavailable:
used: "이 메일 주소는 사용중입니다"
format: "형식이 올바르지 않습니다"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "조정 기록 보기"
"read:admin:show-user": "유저 개인정보 보기"
"write:admin:suspend-user": "유저 정지하기"
"write:admin:unset-mfa": "사용자의 2단계 인증 해제"
"write:admin:unset-user-avatar": "유저 아바타 삭제하기"
"write:admin:unset-user-banner": "유저 배너 삭제하기"
"write:admin:unsuspend-user": "유저 정지 해제하기"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "아바타 장식 만들기"
updateAvatarDecoration: "아바타 장식 수정"
deleteAvatarDecoration: "아바타 장식 삭제"
unsetMfa: "사용자의 2단계 인증 해제"
unsetUserAvatar: "유저 아바타 제거"
unsetUserBanner: "유저 배너 제거"
createSystemWebhook: "SystemWebhook을 생성"
+20 -5
View File
@@ -57,7 +57,7 @@ deleteAndEditConfirm: "要删除该帖并重新编辑吗?该帖下的所有回
addToList: "添加至列表"
addToAntenna: "添加到天线"
sendMessage: "发送消息"
copyRSS: "复制RSS"
copyRSS: "复制 RSS"
copyUsername: "复制用户名"
copyUserId: "复制用户 ID"
copyNoteId: "复制帖子 ID"
@@ -456,8 +456,8 @@ about: "关于"
aboutMisskey: "关于 Misskey"
administrator: "管理员"
token: "Token (令牌)"
2fa: "双因素认证"
setupOf2fa: "设置双因素认证"
2fa: "双重验证"
setupOf2fa: "设置双重验证"
totp: "验证器"
totpDescription: "使用验证器输入一次性密码"
moderator: "监察员"
@@ -485,7 +485,7 @@ markAsReadAllNotifications: "将所有通知标为已读"
markAsReadAllUnreadNotes: "将所有帖子标记为已读"
markAsReadAllTalkMessages: "将所有私信标记为已读"
help: "帮助"
inputMessageHere: "在此入信息"
inputMessageHere: "在此入信息"
close: "关闭"
invites: "邀请"
members: "成员"
@@ -619,6 +619,8 @@ output: "输出"
script: "脚本"
disablePagesScript: "禁用页面脚本"
updateRemoteUser: "更新远程用户信息"
unsetMfa: "解除双重验证"
unsetMfaConfirm: "确认解除双重验证吗?"
unsetUserAvatar: "清除头像"
unsetUserAvatarConfirm: "要清除头像吗?"
unsetUserBanner: "清除横幅"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "添加至表情符号选择器"
emojiPaletteAlreadyAddedConfirm: "此表情符号已存在于此表情符号选择器中。要再次添加吗?"
append: "加到最后"
prepend: "加到最前"
urlPreviewSensitiveList: "限制显示缩略图的 URL"
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
_imageEditing:
_vars:
caption: "文件标题"
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
setSensitiveFlagAutomaticallyDescription: "即使关闭此配置,识别结果也会在内部保存。"
analyzeVideos: "启用对视频的检测"
analyzeVideosDescription: "除了静止图像之外,还对视频进行分析。服务器负载会略微增加。"
externalServiceInfo: "检测敏感媒体已分离至外部服务 (sensitive-detector)。若要使用,需额外部署 Sidecar 服务,并设置下方的连接 URL。未设定时将不会进行检测(视为非敏感媒体)。"
apiUrl: "检测服务的连接 URL"
apiUrlDescription: "sensitive-detector 服务的 base URL(如:http://localhost:3009)。若是连接至部署在专用网络上的服务,请在配置文件中的 allowedPrivateNetworks 里允许目标网络。若是使用了代理,请一并设置 proxyBypassHosts。留空则不进行敏感媒体检测。"
apiKey: "API 密钥"
apiKeyDescription: "若服务端有设置验证(Bearer token)则填写,未设置则留空。"
timeout: "超时(毫秒)"
timeoutDescription: "此为单次检测请求的超时时长。"
maxImagesPerRequest: "单次检测请求最大图像数量"
maxImagesPerRequestDescription: "此为在检测动画等多帧图像时,单次请求中可发送的图像数量上限。超出此值时动画将被拆分并按序发送。请勿将此值设为超出 sensitive-detector 侧的 maxParts 的值(默认:10),否则对应的分块将全被视为非敏感媒体。"
_emailUnavailable:
used: "已经被使用过"
format: "无效的格式"
@@ -2461,6 +2474,7 @@ _permissions:
"read:admin:show-moderation-log": "查看管理日志"
"read:admin:show-user": "查看用户的非公开信息"
"write:admin:suspend-user": "冻结用户"
"write:admin:unset-mfa": "解除用户的双重验证"
"write:admin:unset-user-avatar": "删除用户头像"
"write:admin:unset-user-banner": "删除用户横幅"
"write:admin:unsuspend-user": "解除用户冻结"
@@ -2586,7 +2600,7 @@ _widgetOptions:
_jobQueue:
sound: "播放音效"
_rss:
url: "RSS feed 的 URL"
url: "RSS 订阅源网址"
refreshIntervalSec: "更新间隔(秒)"
maxEntries: "最大显示个数"
_rssTicker:
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "新建头像挂件"
updateAvatarDecoration: "更新头像挂件"
deleteAvatarDecoration: "删除头像挂件"
unsetMfa: "解除用户的双重验证"
unsetUserAvatar: "清除用户头像"
unsetUserBanner: "清除用户横幅"
createSystemWebhook: "新建了 SystemWebhook"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.2",
"version": "2026.7.0-beta.6",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -58,7 +58,7 @@
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "5.2.1",
"tar": "7.5.20"
"tar": "7.5.21"
},
"devDependencies": {
"@eslint/js": "9.39.5",
@@ -1,28 +1,28 @@
## ⚙️ Backend Diagnostics Report
### Memory: After GC
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 1.4 MB |
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 1.4 MB |
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 1.4 MB |
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 1.4 MB |
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 1.4 MB |
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 1.4 MB |
<small><i>Only metrics showing significant changes are displayed.</i></small>
### V8 Heap Snapshot Statistics
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 283 KB |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 283 KB |
| | | | | |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 KB |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 KB |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
@@ -64,7 +64,7 @@ test('throws when filtering leaves fewer than two heap snapshots per side', asyn
})).toThrow('At least two samples per side are required');
});
test('reports the difference of medians and leaves a paired-looking PSS delta uncoloured', async () => {
test('omits a paired-looking PSS delta that stays within observed noise', async () => {
const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]);
const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]);
@@ -78,16 +78,10 @@ test('reports the difference of medians and leaves a paired-looking PSS delta un
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
const row = findMetricRow(markdown, 'PSS');
expect(row).toContain('295.8 MB <br> ± 2.9 MB');
expect(row).toContain('296.3 MB <br> ± 3.4 MB');
expect(row).toContain('$\\text{+0.5 MB}$');
expect(row).toContain('4.5 MB');
expect(row.split('|')).toHaveLength(7);
expect(row).not.toMatch(/within noise|increase|decrease|inconclusive/);
expect(row).not.toContain('\\color{orange}');
expect(findMetricRow(markdown, 'USS')).not.toContain('\\color{orange}');
expect(markdown).not.toContain('| **PSS** |');
expect(markdown).toContain('<small><i>Only metrics showing significant changes are displayed.</i></small>');
expect(markdown).not.toContain('⚠️ **Warning**: Memory usage (PSS)');
});
test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => {
@@ -131,6 +125,6 @@ test('renders an unavailable percentage when the base median is zero', async ()
});
expect(findMetricRow(markdown, 'External')).toContain('<br>-');
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
expect(markdown).toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD | Result |');
});
@@ -131,7 +131,7 @@ export function chunkMarkdownTable(
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [
'| Chunk | Base | Head | Δ | Δ (%) |',
'| Chunk | Base | Head | Δ | Δ (%) |', // nbspにすること
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
@@ -150,7 +150,8 @@ export function chunkMarkdownTable(
}
}
if (hasGenerated) {
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`);
// eslint-disable-next-line no-irregular-whitespace
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること
}
if (hasOther) {
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
@@ -194,7 +194,7 @@ export function renderVisualizerSummaryTable(base: ReturnType<typeof collectVisu
...metrics.map((key) => `<td>${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}</td>`),
'</tr>',
'<tr>',
'<th><b>Δ (%)</b></th>',
'<th><b>Δ&nbsp;(%)</b></th>',
...summary.map((key) => `<td>${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}</td>`),
'</tr>',
@@ -1,12 +1,12 @@
## 🖥 Frontend Diagnostics Report
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| **Encoded network** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+83 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Decoded body** | 3.5 MB <br> ± 34 KB | 3.7 MB <br> ± 37 KB | $\color{orange}{\text{+277 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 50 KB |
| **Same-origin encoded** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+82 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Script encoded** | 918 KB <br> ± 9 KB | 991 KB <br> ± 9.7 KB | $\color{orange}{\text{+73 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 13 KB |
| **Page-attributed memory** | 92 MB <br> ± 900 KB | 99 MB <br> ± 972 KB | $\color{orange}{\text{+7.3 MB}}$<br>$\color{orange}{\text{+8\\%}}$ | 1.3 MB |
| **Encoded network** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+83 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Decoded body** | 3.5 MB <br> ± 34 KB | 3.7 MB <br> ± 37 KB | $\color{orange}{\text{+277 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 50 KB |
| **Same-origin encoded** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+82 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
| **Script encoded** | 918 KB <br> ± 9 KB | 991 KB <br> ± 9.7 KB | $\color{orange}{\text{+73 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 13 KB |
| **Page-attributed memory** | 92 MB <br> ± 900 KB | 99 MB <br> ± 972 KB | $\color{orange}{\text{+7.3 MB}}$<br>$\color{orange}{\text{+8\\%}}$ | 1.3 MB |
<small><i>Only metrics showing significant changes are displayed.</i></small>
@@ -37,27 +37,27 @@
<td align="right">10</td>
<td align="right">10</td>
<td align="right">0</td>
<td align="right">918 KB</td>
<td align="right">991 KB</td>
<td align="right">$\color{orange}{\text{+73 KB}}$</td>
<td align="right">918 KB</td>
<td align="right">991 KB</td>
<td align="right">$\color{orange}{\text{+73 KB}}$</td>
</tr>
<tr>
<td><b>Stylesheet</b></td>
<td align="right">2</td>
<td align="right">2</td>
<td align="right">0</td>
<td align="right">82 KB</td>
<td align="right">88 KB</td>
<td align="right">$\color{orange}{\text{+6.5 KB}}$</td>
<td align="right">82 KB</td>
<td align="right">88 KB</td>
<td align="right">$\color{orange}{\text{+6.5 KB}}$</td>
</tr>
<tr>
<td><b>Fetch</b></td>
<td align="right">6</td>
<td align="right">6</td>
<td align="right">0</td>
<td align="right">41 KB</td>
<td align="right">44 KB</td>
<td align="right">$\color{orange}{\text{+3.3 KB}}$</td>
<td align="right">41 KB</td>
<td align="right">44 KB</td>
<td align="right">$\color{orange}{\text{+3.3 KB}}$</td>
</tr>
</tbody>
</table>
@@ -67,17 +67,17 @@
<details>
<summary>V8 heap snapshot statistics</summary>
| Metric | @ Base | @ Head | Δ | MAD |
| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |
| --- | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\text{+8\\%}$ | 15 KB |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\text{+8\\%}$ | 15 KB |
| | | | | |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB |
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB |
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB |
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB |
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB |
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB |
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB |
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
</details>
@@ -87,25 +87,25 @@ Download representative heap snapshot: [base](https://example.invalid/base) / [h
<details>
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>
| Chunk | Base | Head | Δ | Δ (%) |
| Chunk | Base | Head | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ |
| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ |
| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
</details>
<details>
<summary>Startup chunk size (2 updated, 0 added, 0 removed)</summary>
| Chunk | Base | Head | Δ | Δ (%) |
| Chunk | Base | Head | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
| | | | | |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ |
| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ |
_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
@@ -138,9 +138,9 @@ _Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
<td>1</td>
<td>2</td>
<td>3</td>
<td>21 KB</td>
<td>6.3 KB</td>
<td>5.3 KB</td>
<td>21 KB</td>
<td>6.3 KB</td>
<td>5.3 KB</td>
</tr>
<tr>
<th><b>Head</b></th>
@@ -149,9 +149,9 @@ _Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
<td>1</td>
<td>3</td>
<td>3</td>
<td>31 KB</td>
<td>8.4 KB</td>
<td>7 KB</td>
<td>31 KB</td>
<td>8.4 KB</td>
<td>7 KB</td>
</tr>
<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
<tr>
@@ -161,12 +161,12 @@ _Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._
<td>0</td>
<td>$\color{orange}{\text{+1}}$</td>
<td>0</td>
<td>$\color{orange}{\text{+9.8 KB}}$</td>
<td>$\color{orange}{\text{+2.1 KB}}$</td>
<td>$\color{orange}{\text{+1.8 KB}}$</td>
<td>$\color{orange}{\text{+9.8 KB}}$</td>
<td>$\color{orange}{\text{+2.1 KB}}$</td>
<td>$\color{orange}{\text{+1.8 KB}}$</td>
</tr>
<tr>
<th><b>Δ (%)</b></th>
<th><b>Δ&nbsp;(%)</b></th>
<td>0%</td>
<td>$\color{orange}{\text{+16.7\%}}$</td>
<td>0%</td>
@@ -232,8 +232,8 @@
<div><dt>Round</dt><dd>1</dd></div>
<div><dt>Base count</dt><dd>0</dd></div>
<div><dt>Head count</dt><dd>1</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>MIME</dt><dd>text/javascript</dd></div>
<div><dt>Protocol</dt><dd>h2</dd></div>
<div><dt>Remote</dt><dd>-</dd></div>
@@ -276,8 +276,8 @@
<div><dt>Round</dt><dd>2</dd></div>
<div><dt>Base count</dt><dd>0</dd></div>
<div><dt>Head count</dt><dd>1</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>MIME</dt><dd>text/javascript</dd></div>
<div><dt>Protocol</dt><dd>h2</dd></div>
<div><dt>Remote</dt><dd>-</dd></div>
@@ -320,8 +320,8 @@
<div><dt>Round</dt><dd>3</dd></div>
<div><dt>Base count</dt><dd>0</dd></div>
<div><dt>Head count</dt><dd>1</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>Encoded</dt><dd>50 KB</dd></div>
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
<div><dt>MIME</dt><dd>text/javascript</dd></div>
<div><dt>Protocol</dt><dd>h2</dd></div>
<div><dt>Remote</dt><dd>-</dd></div>
@@ -151,8 +151,8 @@ test('renders one frontend diagnostics markdown report from bundle and browser d
const markdown = await renderReport('https://example.invalid/html');
await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md');
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
expect(markdown).toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |');
expect(markdown).not.toContain('| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD | Result |');
expect(markdown).toContain('<summary>Requests by resource type</summary>');
expect(markdown).toContain('## 📦 Bundle Stats');
});
@@ -177,8 +177,8 @@ test('renders the difference of independent medians instead of the paired median
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('100 <br> ± 0');
expect(row).toContain('200 <br> ± 0');
expect(row).toContain('100 <br> ± 0');
expect(row).toContain('200 <br> ± 0');
expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$');
expect(row).toContain('| 0 |');
expect(row.split('|')).toHaveLength(7);
@@ -251,7 +251,7 @@ test('renders a directional encoded byte delta at the absolute threshold', async
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
});
test('colours absolute and relative deltas together when the row is significant', async () => {
@@ -268,9 +268,9 @@ test('colours absolute and relative deltas together when the row is significant'
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row).toContain('100 MB <br> ± 0 B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |');
expect(row.split('|')).toHaveLength(7);
});
@@ -33,7 +33,7 @@ export function formatNumber(value: number) {
}
export function formatBytes(value: number) {
if (value === 0) return '0 B';
if (value === 0) return '0 B'; // nbspにすること
const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0;
let size = value;
@@ -43,7 +43,8 @@ export function formatBytes(value: number) {
}
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
// eslint-disable-next-line no-irregular-whitespace
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; // nbspにすること
}
/**
@@ -75,9 +76,9 @@ export function formatSecondsAsMs(value: number | null | undefined) {
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
if (delta === 0) return text(0);
const sign = delta > 0 ? '+' : '-';
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta))).replaceAll(' ', '~')}}$`; // "1 B"とかで1とBの間で自動改行させないためnbsp(~)にする
const color = delta > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta))).replaceAll(' ', '~')}}}$`; // "1 B"とかで1とBの間で自動改行させないためnbsp(~)にする
}
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
@@ -20,7 +20,8 @@ function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategor
}
function swatch(category: HeapSnapshotCategory) {
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
// eslint-disable-next-line no-irregular-whitespace
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label.replaceAll(' ', ' ')}**`; // nbspにすること
}
/**
@@ -35,7 +35,8 @@ function formatMedian<T>(
) {
const formatted = row.formatValue(value);
if (row.showMedianMad === false) return formatted;
return `${formatted} <br> ± ${row.formatValue(spread)}`;
// eslint-disable-next-line no-irregular-whitespace
return `${formatted} <br> ± ${row.formatValue(spread)}`; // nbspにすること
}
function formatDelta<T>(
@@ -77,7 +78,7 @@ export function renderMetricComparisonTable<T>(
if (lines.length === 0) return '**(No significant changes)**';
return [
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
...lines,
...(omitted ? ['', '<small><i>Only metrics showing significant changes are displayed.</i></small>'] : []),
@@ -20,15 +20,15 @@ import {
describe('formatBytes', () => {
// 1024ではなく1000区切り。単位が2桁以上なら小数を落とす
test.each([
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
[0, '0 B'],
[999, '999 B'],
[1_000, '1 KB'],
[1_500, '1.5 KB'],
[15_000, '15 KB'],
[1_234_567, '1.2 MB'],
[12_345_678, '12 MB'],
[1_500_000_000, '1.5 GB'],
[1_500_000_000_000, '1,500 GB'],
])('formats %i as %s', (input, expected) => {
expect(formatBytes(input)).toBe(expected);
});
@@ -45,7 +45,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_100_000, 1_200_000, 1_300_000]),
));
expect(row).toContain('$\\text{+100 KB}$');
expect(row).toContain('$\\text{+100 KB}$');
expect(row).not.toContain('within noise');
expect(row).not.toContain('\\color{orange}');
});
@@ -56,7 +56,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_200_000, 1_200_000, 1_200_000]),
));
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
expect(row).not.toContain('increase');
});
@@ -67,7 +67,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_050_000, 1_050_000, 1_050_000]),
));
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
expect(row.split('|')[4]).not.toContain('\\color{');
});
@@ -88,7 +88,7 @@ describe('renderHeapSnapshotTable', () => {
const categoryRow = lines.find(line => line.includes('**Code**'));
expect(lines.slice(0, 2)).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
]);
expect(table).not.toContain('Result');
@@ -31,9 +31,9 @@ describe('renderMetricComparisonTable', () => {
);
expect(table.split('\n')).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20 units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
]);
});
@@ -50,9 +50,9 @@ describe('renderMetricComparisonTable', () => {
);
expect(table.split('\n')).toStrictEqual([
'| Metric | @ Base | @ Head | Δ | MAD |',
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20 units}}$ | 0 units |',
'| <strong>Metric</strong> | 100 units | 120 units | $\\color{orange}{\\text{+20~units}}$ | 0 units |',
'| | | | | |',
]);
});
@@ -62,7 +62,7 @@ describe('renderMetricComparisonTable', () => {
const head = samples(109, 109, 109);
const table = renderMetricComparisonTable(base, head, [defaultRow]);
expect(table).toContain('$\\text{+9 units}$<br>$\\text{+9\\\\%}$');
expect(table).toContain('$\\text{+9~units}$<br>$\\text{+9\\\\%}$');
expect(table).not.toContain('\\color{');
expect(renderMetricComparisonTable(base, head, [defaultRow], {
onlySignificantChanges: true,
@@ -85,7 +85,7 @@ describe('renderMetricComparisonTable', () => {
{ onlySignificantChanges: true },
);
expect(table).toContain('$\\color{orange}{\\text{+10 units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10~units}}$');
expect(table).toContain('$\\color{orange}{\\text{+10\\\\%}}$');
});
@@ -106,11 +106,11 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(inside).toContain('$\\text{+9 units}$');
expect(inside).toContain('$\\text{+9~units}$');
expect(inside).not.toContain('\\color{');
expect(boundary).toContain('$\\text{+30 units}$');
expect(boundary).toContain('$\\text{+30~units}$');
expect(boundary).not.toContain('\\color{');
expect(outside).toContain('$\\color{orange}{\\text{+31 units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31~units}}$');
expect(outside).toContain('$\\color{orange}{\\text{+31\\\\%}}$');
});
@@ -121,7 +121,7 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(table).toContain('$\\color{green}{\\text{-20 units}}$');
expect(table).toContain('$\\color{green}{\\text{-20~units}}$');
expect(table).toContain('$\\color{green}{\\text{-16.7\\\\%}}$');
});
@@ -132,7 +132,7 @@ describe('renderMetricComparisonTable', () => {
[defaultRow],
);
expect(table).toContain('$\\color{orange}{\\text{+20 units}}$<br>-');
expect(table).toContain('$\\color{orange}{\\text{+20~units}}$<br>-');
});
test('propagates the minimum sample contract', () => {
+1 -1
View File
@@ -58,7 +58,7 @@
"@fastify/http-proxy": "11.5.0",
"@fastify/multipart": "10.1.0",
"@fastify/otel": "0.20.1",
"@fastify/static": "10.1.0",
"@fastify/static": "10.1.2",
"@kitajs/html": "4.2.13",
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/emoji-data": "17.0.3",
+3 -1
View File
@@ -10,7 +10,7 @@ import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis';
import type { LogFormat, LogLevelSetting } from './logging/types.js';
import type { AccessLogConfiguration, LogFormat, LogLevelSetting } from './logging/types.js';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
@@ -136,6 +136,7 @@ type Source = {
format?: LogFormat;
level?: LogLevelSetting;
domains?: Record<string, LogLevelSetting> | null;
access?: AccessLogConfiguration;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
@@ -201,6 +202,7 @@ export type Config = {
format?: LogFormat;
level?: LogLevelSetting;
domains?: Record<string, LogLevelSetting> | null;
access?: AccessLogConfiguration;
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
+60 -11
View File
@@ -5,9 +5,12 @@
import { bindThis } from '@/decorators.js';
import { logManager } from './logging/logging-runtime.js';
import type { LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js';
import type { Keyword } from 'color-convert';
// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。
type LegacyData = Record<string, any> | null;
/**
* ロガー名の階層と従来の公開APIを提供する薄い窓口です。
* 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。
@@ -52,6 +55,15 @@ export default class Logger {
});
}
/** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */
@bindThis
private logStructured(level: LogLevel, input: LogEntryInput): void {
this.write({
...input,
level,
});
}
/** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */
@bindThis
public write(input: LogWriteInput): void {
@@ -62,24 +74,35 @@ export default class Logger {
}
/** 処理を継続できない状況を記録します。 */
public error(input: LogEntryInput): void;
public error(error: Error, data?: LegacyData, important?: boolean): void;
public error(message: string, data?: LegacyData, important?: boolean): void;
public error(errorOrMessage: string | Error, data?: LegacyData, important?: boolean): void;
@bindThis
public error(x: string | Error, data?: Record<string, any> | null, important = false): void {
public error(x: LogEntryInput | string | Error, data?: LegacyData, important = false): void {
if (x instanceof Error) {
// エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。
data = data ?? {};
data.e = x;
this.log('error', x.toString(), data, important, undefined, x);
} else if (typeof x === 'object') {
this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important);
} else if (typeof x === 'string') {
this.log('error', x, data, important);
} else {
this.log('error', `${x}`, data, important);
this.logStructured('error', x);
}
}
/** 処理は継続できるものの、改善が必要な状況を記録します。 */
public warn(input: LogEntryInput): void;
public warn(message: string): void;
public warn(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public warn(message: string, data?: Record<string, any> | null, important = false): void {
this.log('warn', message, data, important);
public warn(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('warn', inputOrMessage, data, important);
} else {
this.logStructured('warn', inputOrMessage);
}
}
/** 処理が成功したことを、従来のDONE表示で記録します。 */
@@ -89,14 +112,40 @@ export default class Logger {
}
/** 開発者向けの調査情報を記録します。 */
public debug(input: LogEntryInput): void;
public debug(message: string): void;
public debug(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public debug(message: string, data?: Record<string, any> | null, important = false): void {
this.log('debug', message, data, important);
public debug(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('debug', inputOrMessage, data, important);
} else {
this.logStructured('debug', inputOrMessage);
}
}
/** 通常の動作状況を記録します。 */
public info(input: LogEntryInput): void;
public info(message: string): void;
public info(message: string, data?: LegacyData, important?: boolean): void;
@bindThis
public info(message: string, data?: Record<string, any> | null, important = false): void {
this.log('info', message, data, important);
public info(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void {
if (typeof inputOrMessage === 'string') {
this.log('info', inputOrMessage, data, important);
} else {
this.logStructured('info', inputOrMessage);
}
}
/** 致命的な状況を構造化ログとして記録します。 */
public fatal(input: LogEntryInput): void;
public fatal(message: string): void;
@bindThis
public fatal(inputOrMessage: LogEntryInput | string): void {
if (typeof inputOrMessage === 'string') {
this.logStructured('fatal', { message: inputOrMessage });
} else {
this.logStructured('fatal', inputOrMessage);
}
}
}
@@ -4,7 +4,7 @@
*/
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogRecord } from './types.js';
/** 設定読込前の最小出力処理が外部から受け取る依存関係です。 */
export type BootstrapConsoleBackendDependencies = {
@@ -46,4 +46,9 @@ export class BootstrapConsoleBackend implements LogBackend {
this.dependencies.output(...args);
}
/** 設定前のAccess logは本文を含めず、最小限の情報だけを出力します。 */
public writeAccess(record: AccessLogRecord): void {
this.dependencies.output(`${record.timestamp} ACCESS ${record.method} ${record.route ?? '-'} ${record.statusCode}`);
}
}
@@ -4,7 +4,7 @@
*/
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogRecord } from './types.js';
/** JSON形式のログを1行で出力する処理が外部から受け取る依存関係です。 */
export type JsonConsoleBackendDependencies = {
@@ -28,6 +28,26 @@ type JsonLogRecord = {
readonly trace_flags?: number;
};
/** Access logのJSON出力項目です。通常ログと混同しない形を保ちます。 */
type JsonAccessLogRecord = {
readonly type: 'access';
readonly timestamp: string;
readonly method: string;
readonly route: string | null;
readonly statusCode: number;
readonly durationMs: number;
readonly responseSizeBytes: number | null;
readonly errorType?: string;
readonly requestBody?: AccessLogRecord['requestBody'];
readonly responseBody?: AccessLogRecord['responseBody'];
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
readonly trace_id?: string;
readonly span_id?: string;
readonly trace_flags?: number;
};
const defaultDependencies: JsonConsoleBackendDependencies = {
output: line => console.log(line),
};
@@ -55,6 +75,28 @@ function createJsonLogRecord(record: LogRecord): JsonLogRecord {
};
}
/** Access logから公開する項目だけを選び、1行JSONの形へ整えます。 */
function createJsonAccessLogRecord(record: AccessLogRecord): JsonAccessLogRecord {
return {
type: 'access',
timestamp: record.timestamp,
method: record.method,
route: record.route,
statusCode: record.statusCode,
durationMs: record.durationMs,
responseSizeBytes: record.responseSizeBytes,
...(record.errorType != null ? { errorType: record.errorType } : {}),
...(record.requestBody !== undefined ? { requestBody: record.requestBody } : {}),
...(record.responseBody !== undefined ? { responseBody: record.responseBody } : {}),
processId: record.processId,
isPrimary: record.isPrimary,
workerId: record.workerId,
...(record.traceId != null ? { trace_id: record.traceId } : {}),
...(record.spanId != null ? { span_id: record.spanId } : {}),
...(record.traceFlags != null ? { trace_flags: record.traceFlags } : {}),
};
}
/**
* LogRecordを1行のJSONへ変換し、ログ収集基盤が扱える標準出力へ渡します。
* LoggerやLogManagerから出力形式を切り離し、Pretty形式と同じ記録を共有します。
@@ -75,4 +117,10 @@ export class JsonConsoleBackend implements LogBackend {
// JSON.stringifyが改行などをエスケープするため、1ログ1行の契約を保てます。
this.dependencies.output(JSON.stringify(createJsonLogRecord(record)));
}
/** Access logを1行JSONとして出力します。本文は設定で有効化された場合のみ、秘匿処理済みで含まれます。 */
public writeAccess(record: AccessLogRecord): void {
// JSON.stringifyが改行をエスケープするため、1件を1物理行に保ちます。
this.dependencies.output(JSON.stringify(createJsonAccessLogRecord(record)));
}
}
+4 -1
View File
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogRecord } from './types.js';
/**
* 整形済みのログを実際の出力先へ渡すための共通窓口です。
@@ -13,6 +13,9 @@ export interface LogBackend {
/** ログを一件出力します。 */
write(record: LogRecord): void;
/** Access logを一件出力します。 */
writeAccess?(record: AccessLogRecord): void;
/** 保留中の出力がある場合に、すべて書き出します。 */
flush?(): void | Promise<void>;
+174 -7
View File
@@ -9,11 +9,23 @@ import { envOption } from '@/env.js';
import {
findLegacyLogError,
normalizeLogAttributes,
normalizeLogValue,
serializeLogError,
type LogNormalizationProfile,
} from './LogNormalizer.js';
import type { LogBackend } from './LogBackend.js';
import type { LogLevel, LogLevelSetting, LogRecord, LogRecordInput, LogTraceContextProvider } from './types.js';
import type {
AccessLogConfiguration,
AccessLogRecord,
AccessLogRecordInput,
AccessLogStatusClass,
LogLevel,
LogLevelSetting,
LogRecord,
LogRecordInput,
LogTraceContext,
LogTraceContextProvider,
} from './types.js';
/** ログを出力したプロセスを識別するための情報です。 */
export type LogProcessInfo = {
@@ -43,6 +55,17 @@ export type LogManagerOptions = {
export type LogManagerConfiguration = {
readonly level?: LogLevelSetting;
readonly domains?: Readonly<Record<string, LogLevelSetting>> | null;
readonly access?: AccessLogConfiguration;
};
/** 正規化済みのAccess log設定です。 */
export type ResolvedAccessLogConfiguration = {
readonly statusClasses: readonly AccessLogStatusClass[];
readonly bodies: {
readonly request: boolean;
readonly response: boolean;
readonly maxBytes: number;
};
};
const logLevelOrder: Readonly<Record<LogLevel, number>> = {
@@ -54,6 +77,9 @@ const logLevelOrder: Readonly<Record<LogLevel, number>> = {
};
const validLogLevels = new Set<LogLevelSetting>(['debug', 'info', 'warn', 'error', 'fatal', 'off']);
const validAccessStatusClasses = new Set<AccessLogStatusClass>(['2xx', '3xx', '4xx', '5xx']);
const defaultAccessBodyMaxBytes = 16 * 1024;
const maxAccessBodyBytes = 128 * 1024;
function validateLogLevel(value: unknown, path: string): LogLevelSetting | undefined {
if (typeof value === 'undefined') return undefined;
@@ -69,14 +95,96 @@ function validateDomainName(domain: string): void {
}
}
function resolveConfiguration(configuration: LogManagerConfiguration | undefined): {
/** Access logを明示的に有効化していない場合の設定を作成します。 */
function createDisabledAccessLogConfiguration(): ResolvedAccessLogConfiguration {
return {
statusClasses: [],
bodies: {
request: false,
response: false,
maxBytes: defaultAccessBodyMaxBytes,
},
};
}
/** Access log設定を検証し、本番環境では本文だけを無効化します。 */
function resolveAccessConfiguration(configuration: unknown, nodeEnv: string | undefined): {
readonly access: ResolvedAccessLogConfiguration;
readonly warnings: readonly string[];
} {
if (configuration == null) return { access: createDisabledAccessLogConfiguration(), warnings: [] };
if (typeof configuration !== 'object' || Array.isArray(configuration)) {
throw new Error('logging.access must be an object');
}
const raw = configuration as {
statusClasses?: unknown;
bodies?: unknown;
};
let statusClasses: AccessLogStatusClass[] = [];
if (typeof raw.statusClasses !== 'undefined') {
if (!Array.isArray(raw.statusClasses)) {
throw new Error('logging.access.statusClasses must be an array');
}
statusClasses = [...new Set(raw.statusClasses.map((statusClass, index) => {
if (typeof statusClass !== 'string' || !validAccessStatusClasses.has(statusClass as AccessLogStatusClass)) {
throw new Error(`logging.access.statusClasses[${index}] must be one of 2xx, 3xx, 4xx, or 5xx`);
}
return statusClass as AccessLogStatusClass;
}))];
}
let request = false;
let response = false;
let maxBytes = defaultAccessBodyMaxBytes;
if (typeof raw.bodies !== 'undefined') {
if (typeof raw.bodies !== 'object' || raw.bodies === null || Array.isArray(raw.bodies)) {
throw new Error('logging.access.bodies must be an object');
}
const bodies = raw.bodies as { request?: unknown; response?: unknown; maxBytes?: unknown };
if (typeof bodies.request !== 'undefined' && typeof bodies.request !== 'boolean') {
throw new Error('logging.access.bodies.request must be a boolean');
}
if (typeof bodies.response !== 'undefined' && typeof bodies.response !== 'boolean') {
throw new Error('logging.access.bodies.response must be a boolean');
}
const configuredMaxBytes = bodies.maxBytes;
if (typeof configuredMaxBytes !== 'undefined' && (typeof configuredMaxBytes !== 'number' || !Number.isSafeInteger(configuredMaxBytes) || configuredMaxBytes <= 0 || configuredMaxBytes > maxAccessBodyBytes)) {
throw new Error(`logging.access.bodies.maxBytes must be a positive integer no greater than ${maxAccessBodyBytes}`);
}
request = bodies.request ?? false;
response = bodies.response ?? false;
maxBytes = typeof configuredMaxBytes === 'number' ? configuredMaxBytes : defaultAccessBodyMaxBytes;
}
if (nodeEnv === 'production' && (request || response)) {
return {
access: {
statusClasses,
bodies: { request: false, response: false, maxBytes },
},
warnings: ['logging.access.bodies is disabled in production mode'],
};
}
return {
access: { statusClasses, bodies: { request, response, maxBytes } },
warnings: [],
};
}
/** 通常ログとAccess logの設定をまとめて検証し、起動時警告も返します。 */
function resolveConfiguration(configuration: LogManagerConfiguration | undefined, nodeEnv: string | undefined): {
readonly level: LogLevelSetting | undefined;
readonly domains: readonly (readonly [string, LogLevelSetting])[];
readonly access: ResolvedAccessLogConfiguration;
readonly warnings: readonly string[];
} {
if (configuration == null) return { level: undefined, domains: [] };
if (configuration == null) return { level: undefined, domains: [], access: createDisabledAccessLogConfiguration(), warnings: [] };
const level = validateLogLevel(configuration.level, 'logging.level');
if (configuration.domains == null) return { level, domains: [] };
const access = resolveAccessConfiguration(configuration.access, nodeEnv);
if (configuration.domains == null) return { level, domains: [], ...access };
if (typeof configuration.domains !== 'object' || configuration.domains === null || Array.isArray(configuration.domains)) {
throw new Error('logging.domains must be an object');
}
@@ -90,7 +198,7 @@ function resolveConfiguration(configuration: LogManagerConfiguration | undefined
return [domain, level] as const;
}).sort((left, right) => right[0].length - left[0].length);
return { level, domains };
return { level, domains, ...access };
}
const defaultDependencies: LogManagerDependencies = {
@@ -116,6 +224,7 @@ export class LogManager {
private traceContextProvider: LogTraceContextProvider | undefined;
private configuredLevel: LogLevelSetting | undefined;
private configuredDomains: readonly (readonly [string, LogLevelSetting])[];
private accessConfiguration: ResolvedAccessLogConfiguration;
private shutdownPromise: Promise<void> | undefined;
/**
@@ -136,6 +245,7 @@ export class LogManager {
this.traceContextProvider = undefined;
this.configuredLevel = undefined;
this.configuredDomains = [];
this.accessConfiguration = createDisabledAccessLogConfiguration();
}
/**
@@ -147,10 +257,17 @@ export class LogManager {
}
/** 起動時の既定levelとdomain別levelを適用します。 */
public configure(configuration?: LogManagerConfiguration): void {
const resolved = resolveConfiguration(configuration);
public configure(configuration?: LogManagerConfiguration): readonly string[] {
const resolved = resolveConfiguration(configuration, this.dependencies.getNodeEnv());
this.configuredLevel = resolved.level;
this.configuredDomains = resolved.domains;
this.accessConfiguration = resolved.access;
return resolved.warnings;
}
/** Fastifyフックが参照する正規化済みのAccess log設定を返します。 */
public getAccessLogConfiguration(): ResolvedAccessLogConfiguration {
return this.accessConfiguration;
}
/** 正規化方式を切り替え、既に作成済みのLoggerにも反映します。 */
@@ -163,6 +280,11 @@ export class LogManager {
this.traceContextProvider = provider;
}
/** 現在の処理に紐付くTrace Contextを取得します。 */
public getActiveTraceContext(): LogTraceContext | undefined {
return this.traceContextProvider?.();
}
/** backendに残っているログをflushしてから終了処理を行います。 */
public shutdown(): Promise<void> {
if (this.shutdownPromise != null) return this.shutdownPromise;
@@ -244,4 +366,49 @@ export class LogManager {
this.backend.write(record);
}
/** status classの設定を確認し、Access logの出力対象か判断します。 */
public shouldWriteAccess(statusCode: number): boolean {
if (this.dependencies.isQuiet()) return false;
const statusClass = `${Math.floor(statusCode / 100)}xx` as AccessLogStatusClass;
return validAccessStatusClasses.has(statusClass) && this.accessConfiguration.statusClasses.includes(statusClass);
}
/** Access logのフック自体を登録してよい状態か、quietを含めて判定します。 */
public isAccessLogEnabled(): boolean {
return !this.dependencies.isQuiet() && this.accessConfiguration.statusClasses.length > 0;
}
/** HTTP応答へ共通情報と本文の安全な正規化を加えてAccess logを渡します。 */
public writeAccess(input: AccessLogRecordInput): void {
if (!this.shouldWriteAccess(input.statusCode)) return;
const processInfo = this.dependencies.getProcessInfo();
const { requestBody, responseBody, traceContext, ...inputWithoutOptionalValues } = input;
const bodyOptions = {
profile: this.normalizationProfile,
limits: { maxBytes: this.accessConfiguration.bodies.maxBytes },
} as const;
const normalizedRequestBody = this.accessConfiguration.bodies.request && typeof requestBody !== 'undefined'
? normalizeLogValue(requestBody, bodyOptions)
: undefined;
const normalizedResponseBody = this.accessConfiguration.bodies.response && typeof responseBody !== 'undefined'
? normalizeLogValue(responseBody, bodyOptions)
: undefined;
// HTTPフックが開始時に保持したContextだけを使い、応答時に別のSpanを紐付けません。
const resolvedTraceContext = traceContext;
const record: AccessLogRecord = {
type: 'access',
...inputWithoutOptionalValues,
timestamp: this.dependencies.now().toISOString(),
processId: processInfo.processId,
isPrimary: processInfo.isPrimary,
workerId: processInfo.workerId,
...(typeof normalizedRequestBody !== 'undefined' ? { requestBody: normalizedRequestBody } : {}),
...(typeof normalizedResponseBody !== 'undefined' ? { responseBody: normalizedResponseBody } : {}),
...(resolvedTraceContext ?? {}),
};
this.backend.writeAccess?.(record);
}
}
@@ -60,6 +60,7 @@ const sensitiveKeyParts = [
'apikey',
'privatekey',
'credential',
'captcha',
'hcaptcharesponse',
'grecaptcharesponse',
'turnstileresponse',
@@ -290,6 +291,14 @@ export function normalizeLogAttributes(value: unknown, options: LogNormalization
return trimToByteLimit(root as LogAttributes, limits.maxBytes) as LogAttributes;
}
/** 本文など単独の値を、既存ログと同じ秘匿・切り詰め規則で正規化します。 */
export function normalizeLogValue(value: unknown, options: LogNormalizationOptions = {}): LogAttributeValue {
const limits = resolveLogNormalizationLimits(options);
const redactor = options.redactor ?? defaultLogRedactor;
const normalized = normalizeValue(value, [], 0, new WeakSet<object>(), limits, redactor);
return trimToByteLimit(normalized, limits.maxBytes);
}
/** 旧APIのdata領域から、エラー本体を見つけて構造化した形へ渡します。 */
export function findLegacyLogError(value: unknown): unknown {
if (isErrorValue(value)) return value;
@@ -8,7 +8,7 @@ import { default as convertColor } from 'color-convert';
import { format as dateFormat } from 'date-fns';
import { envOption } from '@/env.js';
import type { LogBackend } from './LogBackend.js';
import type { LogRecord } from './types.js';
import type { AccessLogRecord, LogAttributeValue, LogRecord } from './types.js';
/** 見やすい形式の出力処理が外部から受け取る依存関係です。 */
export type PrettyConsoleBackendDependencies = {
@@ -21,6 +21,15 @@ const defaultDependencies: PrettyConsoleBackendDependencies = {
withLogTime: () => envOption.withLogTime,
};
/** Pretty形式でだけnull prototypeを通常のオブジェクトへ変換し、正規化済みの値自体は変更しません。 */
function toPrettyLogValue(value: LogAttributeValue): LogAttributeValue {
if (Array.isArray(value)) return value.map(item => toPrettyLogValue(item));
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, toPrettyLogValue(child)]));
}
return value;
}
/**
* 人が読みやすい従来形式へ整形し、コンソールへ出力します。
* 色、ラベル、時刻などの見た目だけを担当し、出力可否はLogManagerへ任せます。
@@ -87,4 +96,30 @@ export class PrettyConsoleBackend implements LogBackend {
}
this.dependencies.output(...args);
}
/** Access logを人が読みやすい1行へ整形し、本文は追加情報として表示します。 */
public writeAccess(record: AccessLogRecord): void {
const statusLevel = record.statusCode >= 500 ? 'error' : record.statusCode >= 400 ? 'warn' : 'info';
const label =
statusLevel === 'error' ? chalk.red('ERR ') :
statusLevel === 'warn' ? chalk.yellow('WARN') :
chalk.blue('INFO');
const worker = record.isPrimary ? '*' : record.workerId;
const route = record.route ?? '-';
const size = record.responseSizeBytes == null ? '-' : `${record.responseSizeBytes}B`;
const message = `${record.method} ${route} ${record.statusCode} ${record.durationMs.toFixed(2)}ms ${size}`;
let log = `${label} ${worker}\t[access]\t${message}`;
if (this.dependencies.withLogTime()) {
log = chalk.gray(dateFormat(new Date(record.timestamp), 'HH:mm:ss')) + ' ' + log;
}
const details = {
...(record.errorType != null ? { errorType: record.errorType } : {}),
...(record.requestBody !== undefined ? { requestBody: toPrettyLogValue(record.requestBody) } : {}),
...(record.responseBody !== undefined ? { responseBody: toPrettyLogValue(record.responseBody) } : {}),
};
const args: unknown[] = [log];
if (Object.keys(details).length > 0) args.push(details);
this.dependencies.output(...args);
}
}
@@ -28,8 +28,16 @@ function createLoggingBackend(format: unknown): LogBackend {
export function configureLogging(configuration?: LogManagerConfiguration & { readonly format?: LogFormat }): void {
// 出力処理を先に検証し、設定値が不正な場合は現在の出力処理を壊さないようにします。
const backend = createLoggingBackend(configuration?.format);
logManager.configure(configuration);
const warnings = logManager.configure(configuration);
logManager.setBackend(backend);
// 設定を無視した理由を、選択済みの形式で起動時に一度だけ知らせます。
for (const message of warnings) {
logManager.write({
level: 'warn',
message,
context: [{ name: 'logging' }],
});
}
}
/** Telemetry初期化後に、ログへTrace Contextを付加する取得処理を登録します。 */
+50 -3
View File
@@ -14,6 +14,22 @@ export type LogLevelSetting = LogLevel | 'off';
/** コンソールへ出すログ形式です。未指定時は見やすい形式を使用します。 */
export type LogFormat = 'pretty' | 'json';
/** Access logで対象にできるHTTP status classです。 */
export type AccessLogStatusClass = '2xx' | '3xx' | '4xx' | '5xx';
/** Access logの本文採取設定です。 */
export type AccessLogBodyConfiguration = {
readonly request?: boolean;
readonly response?: boolean;
readonly maxBytes?: number;
};
/** Access logの出力設定です。 */
export type AccessLogConfiguration = {
readonly statusClasses?: readonly AccessLogStatusClass[];
readonly bodies?: AccessLogBodyConfiguration;
};
/** 正規化後にログ属性として扱えるJSONの値です。 */
export type LogAttributeValue =
| string
@@ -36,15 +52,19 @@ export type LogTraceContext = {
/** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */
export type LogTraceContextProvider = () => LogTraceContext | undefined;
/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */
export type LogWriteInput = {
readonly level: LogLevel;
/** level別のLoggerメソッドが受け取る構造化ログの共通入力です。 */
export type LogEntryInput = {
readonly message: string;
readonly eventName?: string;
readonly attributes?: Readonly<Record<string, unknown>>;
readonly error?: unknown;
};
/** 動的なlevelを含めてLogManagerへ渡す構造化ログの入力です。 */
export type LogWriteInput = LogEntryInput & {
readonly level: LogLevel;
};
/**
* ロガー名を構成する一要素です。
* 色は見やすい形式での表示だけに使い、ログの意味には影響させません。
@@ -97,3 +117,30 @@ export type LogRecord = Omit<LogRecordInput, 'attributes' | 'error'> & {
readonly attributes?: LogAttributes;
readonly error?: SerializedError;
};
/** Access logとして記録するHTTP応答の入力です。 */
export type AccessLogRecordInput = {
readonly method: string;
readonly route: string | null;
readonly statusCode: number;
readonly durationMs: number;
readonly responseSizeBytes: number | null;
readonly errorType?: string;
readonly requestBody?: unknown;
readonly responseBody?: unknown;
readonly traceContext?: LogTraceContext;
};
/** 出力先へ渡すAccess logです。本文は正規化後の値だけを含みます。 */
export type AccessLogRecord = Omit<AccessLogRecordInput, 'requestBody' | 'responseBody' | 'traceContext'> & {
readonly type: 'access';
readonly timestamp: string;
readonly processId: number;
readonly isPrimary: boolean;
readonly workerId: number | null;
readonly requestBody?: LogAttributeValue;
readonly responseBody?: LogAttributeValue;
readonly traceId?: string;
readonly spanId?: string;
readonly traceFlags?: number;
};
@@ -33,6 +33,7 @@ import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
import { registerHttpAccessLog } from './http-access-log.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -82,6 +83,7 @@ export class ServerService implements OnApplicationShutdown {
});
this.#fastify = fastify;
await registerHttpServerInstrumentation(fastify, this.config);
registerHttpAccessLog(fastify);
// HSTS
// 6months (15552000sec)
@@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MiMeta, UsersRepository } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@@ -77,6 +78,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private signupService: SignupService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, _me, token) => {
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
@@ -93,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
throw new ApiError(meta.errors.wrongInitialPassword);
}
} else if ((this.serverSettings.rootUserId != null && (this.serverSettings.rootUserId !== me?.id)) || token !== null) {
} else if (token !== null || !(await this.roleService.isAdministrator(me))) {
// 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合
throw new ApiError(meta.errors.accessDenied);
}
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Buffer } from 'node:buffer';
import type { FastifyInstance } from 'fastify';
import { logManager } from '@/logging/logging-runtime.js';
import type { LogManager } from '@/logging/LogManager.js';
import type { LogTraceContext } from '@/logging/types.js';
type AccessRequestState = {
traceContext?: LogTraceContext;
errorType?: string;
requestBody?: unknown;
responseBody?: unknown;
};
type CapturedBody = {
value: unknown;
};
/** Content-Typeから本文の種類だけを取り出します。 */
function getMediaType(value: string | string[] | number | undefined): string | undefined {
const first = Array.isArray(value) ? value[0] : value;
return typeof first === 'string' ? first.split(';', 1)[0].trim().toLowerCase() : undefined;
}
/** 秘匿処理を適用できるJSON・form・text本文か判定します。 */
function isSupportedMediaType(value: string | undefined): boolean {
return value === 'application/json'
|| (value?.startsWith('application/') === true && value.endsWith('+json'))
|| value === 'application/x-www-form-urlencoded'
|| (value?.startsWith('text/') === true && value !== 'text/event-stream');
}
/** application/jsonとapplication/*+jsonを構造化対象として判定します。 */
function isJsonMediaType(value: string | undefined): boolean {
return value === 'application/json'
|| (value?.startsWith('application/') === true && value.endsWith('+json'));
}
/** Streamやバイナリを本文ログの対象から外します。 */
function isStream(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
try {
const candidate = value as {
pipe?: unknown;
getReader?: unknown;
[Symbol.asyncIterator]?: unknown;
};
return typeof candidate.pipe === 'function'
|| typeof candidate.getReader === 'function'
|| typeof candidate[Symbol.asyncIterator] === 'function';
} catch {
// 本文のgetterが壊れていても、ログ処理が応答を失敗させないよう除外します。
return true;
}
}
/** JSON文字列を構造化し、解析できない場合は元の文字列を保持します。 */
function parseJsonBody(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** form本文を項目ごとの値へ分解し、キー単位の秘匿処理を可能にします。 */
function parseFormBody(value: string): Record<string, string | string[]> {
const result: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>;
for (const [key, item] of new URLSearchParams(value)) {
const previous = result[key];
result[key] = typeof previous === 'undefined'
? item
: Array.isArray(previous) ? [...previous, item] : [previous, item];
}
return result;
}
/** 送受信本文から、ログへ渡せる値だけを取り出します。 */
function captureBody(value: unknown, contentType: string | string[] | number | undefined): CapturedBody | undefined {
const mediaType = getMediaType(contentType);
if (!isSupportedMediaType(mediaType) || isStream(value)) return undefined;
if (isJsonMediaType(mediaType)) {
if (typeof value === 'string') return { value: parseJsonBody(value) };
if (Buffer.isBuffer(value)) return { value: parseJsonBody(value.toString('utf8')) };
return { value };
}
if (mediaType === 'application/x-www-form-urlencoded') {
if (typeof value === 'string') return { value: parseFormBody(value) };
if (Buffer.isBuffer(value)) return { value: parseFormBody(value.toString('utf8')) };
}
if (typeof value === 'string') return { value };
if (Buffer.isBuffer(value)) return { value: value.toString('utf8') };
// form parserなどが返す構造化済みの本文も、通常の正規化処理へ渡します。
if (typeof value === 'object' && value !== null) return { value };
return undefined;
}
/** Errorから本文を含めない型名だけを取り出します。 */
function getErrorType(error: unknown): string {
if (typeof error === 'object' && error !== null && typeof (error as { name?: unknown }).name === 'string') {
const name = (error as { name: string }).name;
if (name.length > 0) return name;
}
return 'Error';
}
/** content-lengthを安全なバイト数へ変換し、未知の応答ではnullを返します。 */
function getResponseSize(value: string | string[] | number | undefined): number | null {
const first = Array.isArray(value) ? value[0] : value;
const size = typeof first === 'number' ? first : typeof first === 'string' && /^\d+$/.test(first) ? Number(first) : NaN;
return Number.isSafeInteger(size) && size >= 0 ? size : null;
}
/** Fastify全体へAccess log用のリクエスト・エラー・応答フックを登録します。 */
export function registerHttpAccessLog(fastify: FastifyInstance, manager: LogManager = logManager): void {
if (!manager.isAccessLogEnabled()) return;
const states = new WeakMap<object, AccessRequestState>();
// HTTP計装の後に登録し、リクエスト開始時のactiveなTrace Contextを保存します。
fastify.addHook('onRequest', (request, _reply, done) => {
states.set(request, {
traceContext: manager.getActiveTraceContext(),
});
done();
});
// Fastifyが応答へ変換したErrorから、型名だけをリクエストへ一時保存します。
fastify.addHook('onError', (request, _reply, error, done) => {
const state = states.get(request) ?? {};
state.errorType = getErrorType(error);
states.set(request, state);
done();
});
// 送信前のpayloadを読み取りますが、元の値は変更せず、そのまま次のhookへ渡します。
fastify.addHook('onSend', (request, reply, payload, done) => {
if (!manager.shouldWriteAccess(reply.statusCode)) {
done(null, payload);
return;
}
const bodyConfiguration = manager.getAccessLogConfiguration().bodies;
const state = states.get(request) ?? {};
if (bodyConfiguration.request && !('requestBody' in state)) {
// ActivityPubもFastifyが解析したrequest.bodyだけを対象にし、rawBodyやheaderは参照しません。
const captured = captureBody(request.body, request.headers['content-type']);
if (captured !== undefined) state.requestBody = captured.value;
}
if (bodyConfiguration.response && !('responseBody' in state)) {
const captured = captureBody(payload, reply.getHeader('content-type'));
if (captured !== undefined) state.responseBody = captured.value;
}
states.set(request, state);
done(null, payload);
});
// 応答完了時にFastifyの値を集め、LogManagerへAccess logを渡します。
fastify.addHook('onResponse', (request, reply, done) => {
const state = states.get(request);
const input = {
method: request.method,
route: request.routeOptions.url ?? null,
statusCode: reply.statusCode,
durationMs: reply.elapsedTime,
responseSizeBytes: getResponseSize(reply.getHeader('content-length')),
...(state?.errorType !== undefined ? { errorType: state.errorType } : {}),
...(state != null && 'requestBody' in state ? { requestBody: state.requestBody } : {}),
...(state != null && 'responseBody' in state ? { responseBody: state.responseBody } : {}),
...(state?.traceContext !== undefined ? { traceContext: state.traceContext } : {}),
};
manager.writeAccess(input);
states.delete(request);
done();
});
}
@@ -4,7 +4,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import type { LogRecord } from '@/logging/types.js';
import type { AccessLogRecord, LogRecord } from '@/logging/types.js';
import { JsonConsoleBackend } from '@/logging/JsonConsoleBackend.js';
/** JSON形式のテストで使う共通のログを作成します。 */
@@ -23,6 +23,48 @@ function createRecord(overrides: Partial<LogRecord> = {}): LogRecord {
}
describe('JsonConsoleBackend', () => {
test('writes Access log as a separate one-line schema', () => {
const output = vi.fn<(line: string) => void>();
const backend = new JsonConsoleBackend({ output });
const record: AccessLogRecord = {
type: 'access',
timestamp: '2025-01-02T03:04:05.678Z',
method: 'GET',
route: '/items/:id',
statusCode: 500,
durationMs: 12.5,
responseSizeBytes: null,
errorType: 'TypeError',
requestBody: { token: '[REDACTED]' },
processId: 1234,
isPrimary: true,
workerId: null,
traceId: 'trace',
spanId: 'span',
traceFlags: 1,
};
backend.writeAccess(record);
expect(JSON.parse(output.mock.calls[0][0])).toEqual({
type: 'access',
timestamp: '2025-01-02T03:04:05.678Z',
method: 'GET',
route: '/items/:id',
statusCode: 500,
durationMs: 12.5,
responseSizeBytes: null,
errorType: 'TypeError',
requestBody: { token: '[REDACTED]' },
processId: 1234,
isPrimary: true,
workerId: null,
trace_id: 'trace',
span_id: 'span',
trace_flags: 1,
});
});
test('writes a stable one-line JSON record', () => {
const output = vi.fn<(line: string) => void>();
const backend = new JsonConsoleBackend({ output });
@@ -5,7 +5,7 @@
import { describe, expect, test, vi } from 'vitest';
import type { LogBackend } from '@/logging/LogBackend.js';
import type { LogRecordInput, LogTraceContext } from '@/logging/types.js';
import type { AccessLogRecord, AccessLogRecordInput, LogRecordInput, LogTraceContext } from '@/logging/types.js';
import { LogManager } from '@/logging/LogManager.js';
/** テストで使う最小構成のログ入力を作成します。 */
@@ -31,10 +31,15 @@ function createManager(options: {
configuration?: {
level?: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off';
domains?: Record<string, 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'off'>;
access?: {
statusClasses?: ('2xx' | '3xx' | '4xx' | '5xx')[];
bodies?: { request?: boolean; response?: boolean; maxBytes?: number };
};
};
} = {}) {
const write = vi.fn<LogBackend['write']>();
const manager = new LogManager({ write }, {
const writeAccess = vi.fn<(record: AccessLogRecord) => void>();
const manager = new LogManager({ write, writeAccess }, {
now: () => new Date('2025-01-02T03:04:05.678Z'),
getProcessInfo: () => ({
processId: 1234,
@@ -49,7 +54,7 @@ function createManager(options: {
});
if (options.configuration) manager.configure(options.configuration);
return { manager, write };
return { manager, write, writeAccess };
}
describe('LogManager', () => {
@@ -221,6 +226,89 @@ describe('LogManager', () => {
expect(() => manager.configure({ level: 'notice' as never })).toThrow('logging.level');
expect(() => manager.configure({ domains: { queue: 'notice' as never } })).toThrow('logging.domains.queue');
expect(() => manager.configure({ domains: { 'queue.': 'info' } })).toThrow('invalid domain name');
expect(() => manager.configure({ access: { statusClasses: ['1xx' as never] } })).toThrow('statusClasses');
expect(() => manager.configure({ access: { statusClasses: '4xx' as never } })).toThrow('statusClasses');
expect(() => manager.configure({ access: { bodies: null as never } })).toThrow('bodies');
expect(() => manager.configure({ access: { bodies: { maxBytes: 0 } } })).toThrow('maxBytes');
expect(() => manager.configure({ access: { bodies: { maxBytes: 1.5 } } })).toThrow('maxBytes');
expect(() => manager.configure({ access: { bodies: { maxBytes: 128 * 1024 + 1 } } })).toThrow('maxBytes');
});
test('filters Access log by status class and keeps it independent from application level', () => {
const { manager, write, writeAccess } = createManager({ configuration: { level: 'off', access: { statusClasses: ['4xx', '5xx'] } } });
const input: AccessLogRecordInput = {
method: 'GET',
route: '/items/:id',
statusCode: 200,
durationMs: 1,
responseSizeBytes: 10,
};
manager.writeAccess(input);
manager.writeAccess({ ...input, statusCode: 404 });
manager.writeAccess({ ...input, statusCode: 500 });
expect(write).not.toHaveBeenCalled();
expect(writeAccess).toHaveBeenCalledTimes(2);
expect(writeAccess.mock.calls[0][0]).toMatchObject({ type: 'access', statusCode: 404, processId: 1234, workerId: null });
});
test('normalizes Access log bodies and keeps captured Trace Context', () => {
const { manager, writeAccess } = createManager({
configuration: {
access: {
statusClasses: ['2xx'],
bodies: { request: true, response: true, maxBytes: 1024 },
},
},
});
const input: AccessLogRecordInput = {
method: 'POST',
route: '/body',
statusCode: 200,
durationMs: 2,
responseSizeBytes: 20,
requestBody: { i: 'secret', nested: { password: 'secret' } },
responseBody: { token: 'secret', value: 'visible' },
traceContext: { traceId: 'trace', spanId: 'span', traceFlags: 1 },
};
manager.writeAccess(input);
expect(writeAccess).toHaveBeenCalledWith(expect.objectContaining({
requestBody: { i: '[REDACTED]', nested: { password: '[REDACTED]' } },
responseBody: { token: '[REDACTED]', value: 'visible' },
traceId: 'trace',
spanId: 'span',
}));
});
test('does not write Access log by default and preserves the default body limit', () => {
const { manager, writeAccess } = createManager();
manager.writeAccess({
method: 'GET',
route: '/disabled',
statusCode: 200,
durationMs: 1,
responseSizeBytes: null,
});
expect(manager.getAccessLogConfiguration().bodies.maxBytes).toBe(16 * 1024);
expect(writeAccess).not.toHaveBeenCalled();
});
test('accepts the maximum configured body limit', () => {
const { manager } = createManager({
configuration: {
access: {
statusClasses: ['2xx'],
bodies: { maxBytes: 128 * 1024 },
},
},
});
expect(manager.getAccessLogConfiguration().bodies.maxBytes).toBe(128 * 1024);
});
test('uses a replaced backend for subsequent records', () => {
@@ -7,6 +7,7 @@ import { describe, expect, test } from 'vitest';
import {
findLegacyLogError,
normalizeLogAttributes,
normalizeLogValue,
serializeLogError,
} from '@/logging/LogNormalizer.js';
@@ -25,6 +26,13 @@ describe('LogNormalizer', () => {
});
});
test('normalizes a standalone body value with the same redaction and size rules', () => {
const normalized = normalizeLogValue({ token: 'secret', text: 'x'.repeat(100) }, { limits: { maxBytes: 256 } });
expect(normalized).toMatchObject({ token: '[REDACTED]' });
expect(Buffer.byteLength(JSON.stringify(normalized), 'utf8')).toBeLessThanOrEqual(256);
});
test('marks unsupported objects and invalid dates without throwing', () => {
expect(normalizeLogAttributes({
map: new Map([['key', 'value']]),
@@ -49,6 +57,7 @@ describe('LogNormalizer', () => {
i: 'top-level-token',
request: {
Authorization: 'bearer-token',
captcha: 'captcha-value',
password: 'password',
nested: [{ api_key: 'api-key' }],
},
@@ -57,6 +66,7 @@ describe('LogNormalizer', () => {
i: '[REDACTED]',
request: {
Authorization: '[REDACTED]',
captcha: '[REDACTED]',
password: '[REDACTED]',
nested: [{ api_key: '[REDACTED]' }],
},
@@ -76,6 +76,91 @@ describe('Logger', () => {
}));
});
test('supports structured input through every level-specific method', () => {
const logger = new Logger('root').createSubLogger('child');
const error = new Error('broken');
const input = {
eventName: 'example.failed',
message: 'failed',
attributes: { id: 'id' },
error,
};
logger.debug(input);
logger.info(input);
logger.warn(input);
logger.error(input);
logger.fatal(input);
expect(mocks.write.mock.calls.map(([entry]) => entry.level)).toEqual([
'debug',
'info',
'warn',
'error',
'fatal',
]);
for (const [entry] of mocks.write.mock.calls) {
expect(entry).toMatchObject({
...input,
context: [
{ name: 'root', color: undefined },
{ name: 'child', color: undefined },
],
});
expect(entry).not.toHaveProperty('compatibility');
}
});
test('level-specific methods own the level even for runtime-invalid input', () => {
const logger = new Logger('root');
// @ts-expect-error level is selected by the method rather than the input object
logger.warn({ level: 'error', message: 'warning' });
expect(mocks.write.mock.calls[0][0]).toMatchObject({
level: 'warn',
message: 'warning',
});
});
test('preserves the legacy string signatures for non-error levels', () => {
const logger = new Logger('root');
logger.debug('debug', { source: 'debug' }, true);
logger.info('info', null, true);
logger.warn('warn', { source: 'warn' });
expect(mocks.write.mock.calls.map(([entry]) => entry.compatibility)).toEqual([
{ legacyLevel: undefined, important: true, data: { source: 'debug' } },
{ legacyLevel: undefined, important: true, data: null },
{ legacyLevel: undefined, important: false, data: { source: 'warn' } },
]);
});
test('records a fatal string through the structured API', () => {
new Logger('root').fatal('fatal message');
expect(mocks.write).toHaveBeenCalledWith({
level: 'fatal',
message: 'fatal message',
context: [{ name: 'root', color: undefined }],
});
});
test('preserves the legacy error string signature', () => {
new Logger('root').error('failed', { requestId: 'request' }, true);
expect(mocks.write).toHaveBeenCalledWith({
level: 'error',
message: 'failed',
context: [{ name: 'root', color: undefined }],
compatibility: {
legacyLevel: undefined,
important: true,
data: { requestId: 'request' },
},
});
});
test('uses Error.toString and adds the Error to existing data', () => {
const logger = new Logger('root');
const error = new TypeError('broken');
@@ -4,7 +4,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import type { LogRecord } from '@/logging/types.js';
import type { AccessLogRecord, LogRecord } from '@/logging/types.js';
type Formatter = ((value: unknown) => string) & { white?: Formatter };
@@ -50,6 +50,23 @@ function createRecord(overrides: Partial<LogRecord> = {}): LogRecord {
};
}
/** Access logの表示確認用に、正規化後と同じnull prototypeの本文を作成します。 */
function createAccessRecord(requestBody: AccessLogRecord['requestBody']): AccessLogRecord {
return {
type: 'access',
timestamp: '2025-01-02T03:04:05.678Z',
method: 'POST',
route: '/api/test',
statusCode: 200,
durationMs: 1,
responseSizeBytes: 10,
requestBody,
processId: 1234,
isPrimary: true,
workerId: null,
};
}
describe('PrettyConsoleBackend', () => {
test.each([
{ level: 'error', label: '<red>ERR </red>', message: '<red>message</red>' },
@@ -164,4 +181,23 @@ describe('PrettyConsoleBackend', () => {
},
);
});
test('shows normalized body maps as regular objects in pretty output', () => {
const output = vi.fn();
const backend = new PrettyConsoleBackend({ output, withLogTime: () => false });
const nested = Object.create(null) as Record<string, unknown>;
nested.visible = 'value';
const requestBody = Object.create(null) as Record<string, unknown>;
requestBody.nested = nested;
Object.defineProperty(requestBody, '__proto__', { value: 'safe', enumerable: true });
backend.writeAccess(createAccessRecord(requestBody as AccessLogRecord['requestBody']));
const details = output.mock.calls[0][1] as { requestBody: Record<string, unknown> };
expect(Object.getPrototypeOf(details.requestBody)).toBe(Object.prototype);
expect(Object.getPrototypeOf(details.requestBody.nested)).toBe(Object.prototype);
expect(details.requestBody).toHaveProperty('__proto__', 'safe');
expect(Object.getPrototypeOf(requestBody)).toBe(null);
expect(Object.getPrototypeOf(nested)).toBe(null);
});
});
@@ -0,0 +1,249 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Readable } from 'node:stream';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { LogManager } from '@/logging/LogManager.js';
import type { AccessLogRecord, AccessLogStatusClass } from '@/logging/types.js';
import { registerHttpAccessLog } from '@/server/http-access-log.js';
type TestServer = {
readonly fastify: FastifyInstance;
readonly manager: LogManager;
readonly writeAccess: ReturnType<typeof vi.fn>;
};
type TestManager = {
readonly manager: LogManager;
readonly writeAccess: ReturnType<typeof vi.fn>;
};
/** Access logの動作確認用に固定時刻・プロセス情報を持つManagerを作成します。 */
function createManager(options: {
statusClasses?: AccessLogStatusClass[];
requestBody?: boolean;
responseBody?: boolean;
maxBytes?: number;
nodeEnv?: string;
quiet?: boolean;
} = {}): TestManager {
const writeAccess = vi.fn<(record: AccessLogRecord) => void>();
const manager = new LogManager({ write: vi.fn(), writeAccess }, {
now: () => new Date('2026-07-22T00:00:00.000Z'),
getProcessInfo: () => ({ processId: 123, isPrimary: true, workerId: null }),
isQuiet: () => options.quiet ?? false,
isVerbose: () => false,
getNodeEnv: () => options.nodeEnv ?? 'development',
});
manager.configure({
access: {
statusClasses: options.statusClasses ?? ['2xx', '3xx', '4xx', '5xx'],
bodies: {
request: options.requestBody ?? false,
response: options.responseBody ?? false,
maxBytes: options.maxBytes,
},
},
});
return { manager, writeAccess };
}
/** Access logフックを登録したテスト用Fastifyを作成します。 */
async function createServer(options: Parameters<typeof createManager>[0] = {}): Promise<TestServer> {
const { manager, writeAccess } = createManager(options);
const fastify = Fastify({ logger: false });
registerHttpAccessLog(fastify, manager);
fastify.get('/items/:id', async () => ({ ok: true }));
fastify.get('/bad', async (_request, reply) => reply.code(400).send({ error: 'bad' }));
fastify.get('/fail', async () => {
throw new TypeError('failure');
});
fastify.get('/redirect', async (_request, reply) => reply.redirect('/items/redirect'));
fastify.post('/body', async (request) => ({ echo: request.body, token: 'response-secret' }));
fastify.get('/text', async (_request, reply) => reply.type('text/plain').send('response text'));
fastify.get('/form', async (_request, reply) => reply.type('application/x-www-form-urlencoded').send('i=form-token&password=form-password&visible=yes'));
fastify.get('/binary', async (_request, reply) => reply.type('application/octet-stream').send(Buffer.from('binary')));
fastify.get('/stream', async (_request, reply) => reply.type('text/plain').send(Readable.from(['stream body'])));
await fastify.ready();
return { fastify, manager, writeAccess };
}
const servers: FastifyInstance[] = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => server.close()));
});
describe('registerHttpAccessLog', () => {
test('filters responses by configured status classes and keeps the route template', async () => {
const server = await createServer({ statusClasses: ['4xx', '5xx'] });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/items/secret?id=hidden' });
await server.fastify.inject({ method: 'GET', url: '/bad' });
await server.fastify.inject({ method: 'GET', url: '/fail' });
await server.fastify.inject({ method: 'GET', url: '/missing?token=hidden' });
expect(server.writeAccess).toHaveBeenCalledTimes(3);
expect(server.writeAccess.mock.calls.map(call => call[0])).toEqual(expect.arrayContaining([
expect.objectContaining({ route: '/bad', statusCode: 400 }),
expect.objectContaining({ route: '/fail', statusCode: 500, errorType: 'TypeError' }),
expect.objectContaining({ route: null, statusCode: 404 }),
]));
expect(server.writeAccess.mock.calls[0][0]).not.toHaveProperty('requestUrl');
expect(server.writeAccess.mock.calls.find(call => call[0].statusCode === 404)?.[0]).not.toHaveProperty('errorType');
});
test('records redirects and response size when the status class is selected', async () => {
const server = await createServer({ statusClasses: ['3xx'] });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/redirect' });
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({
method: 'GET',
route: '/redirect',
statusCode: 302,
responseSizeBytes: expect.any(Number),
}));
});
test('captures and redacts JSON request and response bodies in development', async () => {
const server = await createServer({ requestBody: true, responseBody: true });
servers.push(server.fastify);
await server.fastify.inject({
method: 'POST',
url: '/body',
headers: { 'content-type': 'application/json' },
payload: { i: 'request-token', nested: { password: 'request-password' }, value: 'visible' },
});
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({
requestBody: {
i: '[REDACTED]',
nested: { password: '[REDACTED]' },
value: 'visible',
},
responseBody: {
echo: {
i: '[REDACTED]',
nested: { password: '[REDACTED]' },
value: 'visible',
},
token: '[REDACTED]',
},
}));
});
test('captures text but omits binary and stream bodies', async () => {
const server = await createServer({ statusClasses: ['2xx'], responseBody: true });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/text' });
await server.fastify.inject({ method: 'GET', url: '/binary' });
await server.fastify.inject({ method: 'GET', url: '/stream' });
expect(server.writeAccess.mock.calls[0][0]).toHaveProperty('responseBody', 'response text');
expect(server.writeAccess.mock.calls[1][0]).not.toHaveProperty('responseBody');
expect(server.writeAccess.mock.calls[2][0]).not.toHaveProperty('responseBody');
});
test('parses form bodies before redaction', async () => {
const server = await createServer({ statusClasses: ['2xx'], responseBody: true });
servers.push(server.fastify);
await server.fastify.inject({ method: 'GET', url: '/form' });
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({
responseBody: {
i: '[REDACTED]',
password: '[REDACTED]',
visible: 'yes',
},
}));
});
test('truncates normalized bodies to the configured limit', async () => {
const server = await createServer({ requestBody: true, responseBody: true, maxBytes: 1024 });
servers.push(server.fastify);
await server.fastify.inject({
method: 'POST',
url: '/body',
headers: { 'content-type': 'application/json' },
payload: { value: 'x'.repeat(20_000) },
});
const record = server.writeAccess.mock.calls[0][0];
expect(Buffer.byteLength(JSON.stringify(record.requestBody), 'utf8')).toBeLessThanOrEqual(1024);
expect(Buffer.byteLength(JSON.stringify(record.responseBody), 'utf8')).toBeLessThanOrEqual(1024);
});
test('preserves the response payload and reports an unknown stream size', async () => {
const server = await createServer({ statusClasses: ['2xx'], responseBody: true });
servers.push(server.fastify);
const response = await server.fastify.inject({ method: 'GET', url: '/stream' });
expect(response.body).toBe('stream body');
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining({ responseSizeBytes: null }));
});
test('does not capture bodies in production and returns a warning', async () => {
const { manager, writeAccess } = createManager({ nodeEnv: 'production', requestBody: true, responseBody: true });
const warnings = manager.configure({ access: { statusClasses: ['2xx'], bodies: { request: true, response: true } } });
const fastify = Fastify({ logger: false });
registerHttpAccessLog(fastify, manager);
fastify.post('/body', async request => ({ body: request.body }));
await fastify.ready();
servers.push(fastify);
await fastify.inject({ method: 'POST', url: '/body', headers: { 'content-type': 'application/json' }, payload: { token: 'hidden' } });
expect(warnings).toEqual(['logging.access.bodies is disabled in production mode']);
expect(writeAccess).toHaveBeenCalledWith(expect.not.objectContaining({ requestBody: expect.anything(), responseBody: expect.anything() }));
});
test('keeps the request Trace Context through response completion', async () => {
const server = await createServer({ statusClasses: ['2xx'] });
servers.push(server.fastify);
const traceContext = { traceId: 'trace', spanId: 'span', traceFlags: 1 };
const provider = vi.fn(() => traceContext);
server.manager.setTraceContextProvider(provider);
await server.fastify.inject({ method: 'GET', url: '/items/trace' });
expect(provider).toHaveBeenCalledOnce();
expect(server.writeAccess).toHaveBeenCalledWith(expect.objectContaining(traceContext));
});
test('omits a Trace Context that was not active at request start', async () => {
const server = await createServer({ statusClasses: ['2xx'] });
servers.push(server.fastify);
const provider = vi.fn()
.mockReturnValueOnce(undefined)
.mockReturnValue({ traceId: 'late-trace', spanId: 'late-span', traceFlags: 1 });
server.manager.setTraceContextProvider(provider);
await server.fastify.inject({ method: 'GET', url: '/items/no-trace' });
expect(server.writeAccess.mock.calls[0][0]).not.toHaveProperty('traceId');
expect(provider).toHaveBeenCalledOnce();
});
test('does not write in quiet mode', async () => {
const server = await createServer({ quiet: true });
servers.push(server.fastify);
const provider = vi.fn(() => ({ traceId: 'trace', spanId: 'span', traceFlags: 1 }));
server.manager.setTraceContextProvider(provider);
await server.fastify.inject({ method: 'GET', url: '/items/quiet' });
expect(provider).not.toHaveBeenCalled();
expect(server.writeAccess).not.toHaveBeenCalled();
});
});
-5
View File
@@ -13,8 +13,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}
-6
View File
@@ -14,9 +14,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TagCanvas: any;
}
+59
View File
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** ドキュメントがアクティブになっていない間は実行を止めるsetInterval。戻り値の関数でdisposeする */
export function createVisibilityAwareInterval(fn: () => void, interval: number, options: {
immediate?: boolean;
} = {}): () => void {
let intervalId: number | null = null;
let lastCalledAt: number | null = null;
const tick = () => {
lastCalledAt = Date.now();
fn();
};
const start = () => {
if (intervalId != null) return;
if (lastCalledAt == null) {
if (options.immediate) {
tick();
} else {
lastCalledAt = Date.now();
}
} else if (Date.now() - lastCalledAt >= interval) {
// もし前回の呼び出しからinterval以上の時間が経過していたら、即座に呼び出す
// (非アクティブ→アクティブに戻った場合など)
tick();
}
intervalId = window.setInterval(tick, interval);
};
const stop = () => {
if (intervalId != null) {
window.clearInterval(intervalId);
intervalId = null;
}
};
const onVisibilityChange = () => {
if (window.document.visibilityState === 'visible') {
start();
} else {
stop();
}
};
window.document.addEventListener('visibilitychange', onVisibilityChange);
if (window.document.visibilityState === 'visible') {
start();
}
return () => {
window.document.removeEventListener('visibilitychange', onVisibilityChange);
stop();
};
}
+26 -13
View File
@@ -4,34 +4,47 @@
*/
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
import { createVisibilityAwareInterval } from './interval.js';
export function useInterval(fn: () => void, interval: number, options: {
immediate: boolean;
afterMounted: boolean;
keepRunningWhenHidden?: boolean;
}): (() => void) | undefined {
if (Number.isNaN(interval)) return;
let intervalId: number | null = null;
let disposer: (() => void) | null = null;
const start = () => {
if (options.keepRunningWhenHidden) {
if (options.immediate) fn();
const intervalId = window.setInterval(fn, interval);
disposer = () => {
window.clearInterval(intervalId);
};
} else {
disposer = createVisibilityAwareInterval(fn, interval, { immediate: options.immediate });
}
};
const clear = () => {
if (disposer) {
disposer();
disposer = null;
}
};
if (options.afterMounted) {
onMounted(() => {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
start();
});
} else {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
start();
}
const clear = () => {
if (intervalId) window.clearInterval(intervalId);
intervalId = null;
};
onActivated(() => {
if (intervalId) return;
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
if (disposer) return;
start();
});
onDeactivated(() => {
@@ -458,7 +458,6 @@ function toStories(component: string): Promise<string> {
globSync('src/components/MkSignupServerRules.vue'),
globSync('src/components/MkUserSetupDialog.vue'),
globSync('src/components/MkUserSetupDialog.*.vue'),
globSync('src/components/MkImgPreviewDialog.vue'),
globSync('src/components/MkInstanceCardMini.vue'),
globSync('src/components/MkInviteCode.vue'),
globSync('src/components/MkTagItem.vue'),
-5
View File
@@ -13,8 +13,3 @@ declare const _PERF_PREFIX_: string;
// for dev-mode
declare const _LANGS_FULL_: string[][];
// TagCanvas
interface Window {
TagCanvas: any;
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -22,6 +22,7 @@
"@mcaptcha/core-glue": "0.1.0-alpha-5",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/tagcanvas-es": "0.1.2",
"@sentry/vue": "10.65.0",
"@simplewebauthn/browser": "13.3.0",
"@syuilo/aiscript": "1.2.1",
@@ -87,7 +87,7 @@ onDeactivated(() => {
useInterval(() => {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (!window.document.hidden && isActivated) {
if (isActivated) {
fetchHistory();
}
}, 1000 * 10, {
@@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { StoryObj } from '@storybook/vue3';
import { file } from '../../.storybook/fakes.js';
import MkImgPreviewDialog from './MkImgPreviewDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkImgPreviewDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkImgPreviewDialog v-bind="props" />',
};
},
args: {
file: file(),
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkImgPreviewDialog>;
@@ -1,63 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="modal"
:width="1800"
:height="900"
@close="close"
@esc="close"
@click="close"
@closed="emit('closed')"
>
<template #header>{{ file.name }}</template>
<div :class="$style.container">
<img :src="file.url" :alt="file.comment || file.name" :class="$style.img"/>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkModalWindow from './MkModalWindow.vue';
import type * as Misskey from 'misskey-js';
defineProps<{
file: Misskey.entities.DriveFile;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const modal = ref<typeof MkModalWindow | null>(null);
function close() {
modal.value?.close();
}
</script>
<style lang="scss" module>
.container {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--MI_THEME-bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
}
.img {
width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@pointerup.passive="onPointerup"
@pointercancel.passive="cancelPointerGesture"
@touchstart.passive="onTouchstart"
@touchmove.passive="onTouchmove"
@touchmove="onTouchmove"
@touchcancel.passive="cancelPointerGesture"
@contextmenu="cancelPointerGesture"
@wheel="onWheel"
@@ -284,7 +284,7 @@ function shouldHideInGallery(content: Content): boolean {
if (!hiddenByDefault) return false;
// ギャラリー起動時に最初に開いたセンシティブ画像だけは初期表示で隠さない
if (content.file.isSensitive && prefer.s.nsfw !== 'force' && props.initiallyOpened) {
if (content.file.isSensitive && props.initiallyOpened) {
return false;
}
@@ -721,6 +721,13 @@ function onTouchstart(ev: TouchEvent) {
function onTouchmove(ev: TouchEvent) {
doubleTapDetector.onTouchmove(ev);
// スワイプ操作中は、ブラウザがタッチを慣性スクロールとして認識するのを防ぐためにpreventDefaultする必要がある
// touch-action: noneが指定されているが、それだけではブラウザによっては慣性スクロールが発生した扱いになり、
// その後のタップが慣性スクロールを止めるためのタップという扱いで握りつぶされてしまうことがあるので必要
if (isVerticalSwiping || isHorizontalSwiping || isZooming.value || pointerEventCache.size > 1) {
ev.preventDefault();
}
}
//#region inertia
@@ -48,13 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue';
import { ref, watch, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
import XItem from './MkLightbox.item.vue';
import type { Content } from './MkLightbox.item.vue';
import type { Keymap } from '@/utility/hotkey.js';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { isTouchUsing } from '@/utility/touch.js';
import { focusTrap } from '@/utility/focus-trap.js';
const props = withDefaults(defineProps<{
defaultIndex?: number;
@@ -66,6 +67,7 @@ const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const rootEl = useTemplateRef('rootEl');
const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem> | null>();
const currentIndex = ref(props.defaultIndex ?? 0);
@@ -192,7 +194,22 @@ function onPopState() {
}
}
let releaseFocusTrap: (() => void) | null = null;
onMounted(() => {
watch([showing], ([showing]) => {
if (showing === true) {
if (rootEl.value != null) {
const { release } = focusTrap(rootEl.value);
releaseFocusTrap = release;
rootEl.value.focus();
}
} else {
releaseFocusTrap?.();
}
}, { immediate: true });
window.history.pushState(null, '', '#pswp');
window.addEventListener('popstate', onPopState);
});
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @pointerup="onClick" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -61,8 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="image.comment" :class="$style.indicator">ALT</div>
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--MI_THEME-warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</template>
</div>
</template>
@@ -137,8 +137,8 @@ async function openGallery(id?: string) {
type: media.type.startsWith('video') ? 'video' : 'image',
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
width: media.properties.width,
height: media.properties.height,
filename: media.name,
file: media,
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot" @pointerup="emit('mediaClick', $event)">
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
@@ -41,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-player-play"></i>
</div>
</div>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @pointerup.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @pointerup.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true"><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
</div>
</div>
</template>
@@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="isEnabledUrlPreview">
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
</div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<div v-if="appearNote.renoteId" :class="$style.quote"><MkNoteSimple :note="appearNote?.renote ?? null" :class="$style.quoteNote"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
@@ -113,7 +113,6 @@ const showContent = ref(false);
.deleted {
text-align: center;
padding: 8px !important;
margin: 8px 8px 0 8px;
--color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
@@ -153,6 +153,7 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
if (menuShowing) return;
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const menuItems: MenuItem[] = [];
@@ -170,13 +171,25 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
action: () => { describe(file); },
});
if (isImage) {
if (isImage || isVideo) {
menuItems.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImgPreviewDialog.vue').then(x => x.default), {
file: file,
const constents = props.modelValue.filter(item => item.type.startsWith('image') || item.type.startsWith('video')).map(item => ({
id: item.id,
type: item.type.startsWith('video') ? 'video' as const : 'image' as const,
url: item.url,
thumbnailUrl: item.thumbnailUrl,
width: item.properties.width,
height: item.properties.height,
filename: item.name,
file: item,
//sourceElement: TODO
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: constents.findIndex(content => content.id === file.id),
contents: constents,
}, {
closed: () => dispose(),
});
@@ -19,15 +19,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.newBg2"></div>
<button class="_button" :class="$style.newButton" @click="releaseQueue()"><i class="ti ti-circle-arrow-up"></i> {{ i18n.ts.newNote }}</button>
</div>
<div :class="$style.notes">
<component
:is="prefer.s.animation ? MkStreamingTimelineItem : 'div'"
v-for="(note, i) in paginator.items.value"
v-bind="prefer.s.animation ? { animatingIn: note._shouldAnimateIn_, animatingOut: note._shouldAnimateOut_ } : {}"
:key="note.id"
:data-scroll-anchor="note.id"
>
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt) && paginator.items.value[i -1]._shouldAnimateOut_ !== true">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="$style.notes"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass="$style.transition_x_move"
tag="div"
>
<template v-for="(note, i) in paginator.items.value" :key="note.id">
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, note.createdAt)" :data-scroll-anchor="note.id">
<div :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, note.createdAt)?.prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
@@ -35,15 +38,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
</div>
<div v-else-if="note._shouldInsertAd_">
<div v-else-if="note._shouldInsertAd_" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true"/>
</component>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else :inline="true"/>
@@ -53,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, onUnmounted, provide, useTemplateRef, onMounted, markRaw } from 'vue';
import { computed, watch, onUnmounted, provide, useTemplateRef, TransitionGroup, onMounted, shallowRef, ref, markRaw } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
@@ -69,10 +72,10 @@ import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import MkNote from '@/components/MkNote.vue';
import MkStreamingTimelineItem, { ITEM_REMOVAL_MS } from '@/components/MkStreamingTimelineItem.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { DI } from '@/di.js';
import { useGlobalEvent } from '@/events.js';
import { globalEvents, useGlobalEvent } from '@/events.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
import { Paginator } from '@/utility/paginator.js';
@@ -101,8 +104,6 @@ provide('inTimeline', true);
provide('tl_withSensitive', computed(() => props.withSensitive));
provide(DI.inChannel, computed(() => props.src === 'channel' ? props.channel ?? null : null));
const itemRemovalDelay = prefer.s.animation ? ITEM_REMOVAL_MS : false;
let paginator: IPaginator<Misskey.entities.Note>;
if (props.src === 'antenna') {
@@ -111,7 +112,6 @@ if (props.src === 'antenna') {
antennaId: props.antenna!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'home') {
paginator = markRaw(new Paginator('notes/timeline', {
@@ -120,7 +120,6 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'local') {
paginator = markRaw(new Paginator('notes/local-timeline', {
@@ -130,7 +129,6 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'social') {
paginator = markRaw(new Paginator('notes/hybrid-timeline', {
@@ -140,7 +138,6 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'global') {
paginator = markRaw(new Paginator('notes/global-timeline', {
@@ -149,12 +146,10 @@ if (props.src === 'antenna') {
withFiles: props.onlyFiles ? true : undefined,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'mentions') {
paginator = markRaw(new Paginator('notes/mentions', {
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'directs') {
paginator = markRaw(new Paginator('notes/mentions', {
@@ -162,7 +157,6 @@ if (props.src === 'antenna') {
visibility: 'specified',
},
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'list') {
paginator = markRaw(new Paginator('notes/user-list-timeline', {
@@ -172,7 +166,6 @@ if (props.src === 'antenna') {
listId: props.list!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'channel') {
paginator = markRaw(new Paginator('channels/timeline', {
@@ -180,7 +173,6 @@ if (props.src === 'antenna') {
channelId: props.channel!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else if (props.src === 'role') {
paginator = markRaw(new Paginator('roles/notes', {
@@ -188,7 +180,6 @@ if (props.src === 'antenna') {
roleId: props.role!,
})),
useShallowRef: true,
itemRemovalDelay,
}));
} else {
throw new Error('Unrecognized timeline type: ' + props.src);
@@ -437,16 +428,45 @@ defineExpose({
</script>
<style lang="scss" module>
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.note,
.note {
/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_leaveTo {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notes {
container-type: inline-size;
background: var(--MI_THEME-panel);
}
.date,
.note {
box-sizing: border-box;
}
.note:not(:empty) {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
@@ -14,15 +14,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else ref="rootEl">
<div :class="$style.notifications">
<component
:is="prefer.s.animation ? MkStreamingTimelineItem : 'div'"
v-for="(notification, i) in paginator.items.value"
v-bind="prefer.s.animation ? { animatingIn: notification._shouldAnimateIn_, animatingOut: notification._shouldAnimateOut_ } : {}"
:key="notification.id"
:data-scroll-anchor="notification.id"
:class="$style.item"
>
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.notifications]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
:moveClass="$style.transition_x_move"
tag="div"
>
<div v-for="(notification, i) in paginator.items.value" :key="notification.id" :data-scroll-anchor="notification.id" :class="$style.item">
<div v-if="i > 0 && isSeparatorNeeded(paginator.items.value[i -1].createdAt, notification.createdAt)" :class="$style.date">
<span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(paginator.items.value[i -1].createdAt, notification.createdAt)?.prevText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
@@ -30,8 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type) && 'note' in notification" :class="$style.content" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :class="$style.content" :notification="notification" :withTime="true" :full="true"/>
</component>
</div>
</div>
</component>
<button v-show="paginator.canFetchOlder.value" key="_more_" v-appear="prefer.s.enableInfiniteScroll ? paginator.fetchOlder : null" :disabled="paginator.fetchingOlder.value" class="_button" :class="$style.more" @click="paginator.fetchOlder">
<div v-if="!paginator.fetchingOlder.value">{{ i18n.ts.loadMore }}</div>
<MkLoading v-else/>
@@ -41,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onUnmounted, onMounted, computed, useTemplateRef, markRaw, watch } from 'vue';
import { onUnmounted, onMounted, computed, useTemplateRef, TransitionGroup, markRaw, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { notificationTypes } from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
@@ -52,7 +53,6 @@ import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import MkStreamingTimelineItem, { ITEM_REMOVAL_MS } from '@/components/MkStreamingTimelineItem.vue';
import { prefer } from '@/preferences.js';
import { store } from '@/store.js';
import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
@@ -64,20 +64,16 @@ const props = defineProps<{
const rootEl = useTemplateRef('rootEl');
const itemRemovalDelay = prefer.s.animation ? ITEM_REMOVAL_MS : false;
const paginator = prefer.s.useGroupedNotifications ? markRaw(new Paginator('i/notifications-grouped', {
limit: 20,
computedParams: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
itemRemovalDelay,
})) : markRaw(new Paginator('i/notifications', {
limit: 20,
computedParams: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined,
})),
itemRemovalDelay,
}));
const MIN_POLLING_INTERVAL = 1000 * 10;
@@ -182,6 +178,9 @@ onMounted(() => {
onUnmounted(() => {
if (connection) connection.dispose();
if (scrollContainer != null) {
scrollContainer.removeEventListener('scroll', onScrollContainerScroll);
}
});
defineExpose({
@@ -190,8 +189,38 @@ defineExpose({
</script>
<style lang="scss" module>
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
.transition_x_move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
.transition_x_enterActive {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
&.content,
.content {
/* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
content-visibility: visible !important;
}
}
.transition_x_leaveActive {
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateY(max(-64px, -100%));
}
@supports (interpolate-size: allow-keywords) {
.transition_x_enterFrom {
interpolate-size: allow-keywords; // heighttransition
height: 0;
}
}
.transition_x_leaveTo {
opacity: 0;
}
.notifications {
@@ -199,6 +228,10 @@ defineExpose({
background: var(--MI_THEME-panel);
}
.item {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
.date {
display: flex;
font-size: 85%;
@@ -1,117 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="rootEl"
:class="[$style.root, {
[$style.enter]: animatingIn,
[$style.leave]: animatingOut,
[$style.animating]: animating,
}]"
@animationstart="animating = true"
@animationend="animating = false"
>
<div ref="innerEl">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
export const ITEM_REMOVAL_MS = 200;
const supportsInterpolateSize = CSS.supports('interpolate-size: allow-keywords');
let resizeObserver: ResizeObserver | null = null;
if (!supportsInterpolateSize) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const root = target.parentElement;
if (root != null) {
root.style.setProperty('--child-height', `${entry.contentRect.height}px`);
}
}
});
}
</script>
<script setup lang="ts">
import { useTemplateRef, onMounted, onBeforeUnmount, ref } from 'vue';
const props = defineProps<{
animatingIn?: boolean;
animatingOut?: boolean;
}>();
const rootEl = useTemplateRef('rootEl');
const innerEl = useTemplateRef('innerEl');
const animating = ref(false);
onMounted(() => {
if (resizeObserver != null && rootEl.value != null && innerEl.value != null) {
resizeObserver.observe(innerEl.value);
rootEl.value.style.setProperty('--child-height', `${innerEl.value.getBoundingClientRect().height}px`);
}
});
onBeforeUnmount(() => {
if (resizeObserver != null && innerEl.value != null) {
resizeObserver.unobserve(innerEl.value);
}
});
</script>
<style module lang="scss">
.animating {
overflow: clip;
}
.inner {
display: flow-root;
}
.enter {
animation: enterAnim 0.7s cubic-bezier(0.23, 1, 0.32, 1) both;
}
.leave {
animation: leaveAnim 0.2s cubic-bezier(0,.5,.5,1) both;
}
@supports (interpolate-size: allow-keywords) {
.root {
interpolate-size: allow-keywords;
}
}
@keyframes enterAnim {
from {
height: 0;
opacity: 0;
transform: translateY(max(-64px, -100%));
}
to {
height: var(--child-height, auto);
opacity: 1;
transform: translateY(0);
}
}
@keyframes leaveAnim {
from {
height: var(--child-height, auto);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
</style>
+45 -39
View File
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="$style.root">
<canvas :id="idForCanvas" ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div :id="idForTags" ref="tagsEl" :class="$style.tags">
<canvas ref="canvasEl" style="display: block;" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div ref="tagsEl" :class="$style.tags">
<ul>
<slot></slot>
</ul>
@@ -15,62 +15,68 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, watch, onBeforeUnmount, ref, useTemplateRef } from 'vue';
import { onMounted, onBeforeUnmount, nextTick, ref, shallowRef, useTemplateRef } from 'vue';
import { themeManager } from '@/theme.js';
import tinycolor from 'tinycolor2';
import { TagCanvas } from '@misskey-dev/tagcanvas-es';
const loaded = !!window.TagCanvas;
const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz';
const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join('');
const available = ref(false);
const rootEl = useTemplateRef('rootEl');
const canvasEl = useTemplateRef('canvasEl');
const tagsEl = useTemplateRef('tagsEl');
const tagCanvas = shallowRef<TagCanvas | null>(null);
const width = ref(300);
watch(available, () => {
try {
window.TagCanvas.Start(idForCanvas, idForTags, {
textColour: '#ffffff',
outlineColour: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
//dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
} catch (err) {}
});
function createTagCanvas() {
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
if (tagsEl.value == null || canvasEl.value == null) return;
if (tagsEl.value.children[0].children.length === 0) return;
tagCanvas.value = new TagCanvas(canvasEl.value, {
tagContainer: tagsEl.value,
textColor: '#ffffff',
outlineColor: tinycolor(themeManager.currentCompiledTheme!.accent).toHexString(),
outlineRadius: 10,
initial: [-0.030, -0.010],
frontSelect: true,
imageRadius: 8,
// dragControl: true,
dragThreshold: 3,
wheelZoom: false,
reverse: true,
depth: 0.5,
maxSpeed: 0.2,
minSpeed: 0.003,
stretchX: 0.8,
stretchY: 0.8,
});
}
onMounted(() => {
if (rootEl.value) width.value = rootEl.value.offsetWidth;
if (loaded) {
available.value = true;
} else {
window.document.head.appendChild(Object.assign(window.document.createElement('script'), {
async: true,
src: '/client-assets/tagcanvas.min.js',
})).addEventListener('load', () => available.value = true);
}
nextTick(() => {
createTagCanvas();
});
});
onBeforeUnmount(() => {
if (window.TagCanvas) window.TagCanvas.Delete(idForCanvas);
if (tagCanvas.value) {
tagCanvas.value.destroy();
tagCanvas.value = null;
}
});
defineExpose({
update: () => {
window.TagCanvas.Update(idForCanvas);
if (tagCanvas.value) {
tagCanvas.value.update();
} else {
createTagCanvas();
}
},
});
</script>
@@ -49,13 +49,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onUnmounted, watch } from 'vue';
import { isLink } from '@@/js/is-link.js';
import { getUploadName } from '@/composables/use-uploader.js';
import type { UploaderItem } from '@/composables/use-uploader.js';
import { getUploadName } from '@/composables/use-uploader.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js';
import * as os from '@/os.js';
const props = defineProps<{
items: UploaderItem[];
@@ -71,6 +72,32 @@ const emit = defineEmits<{
(ev: 'showMenuViaContextmenu', item: UploaderItem, event: PointerEvent): void;
}>();
//#region objectUrlMap
const objectUrlMap = new Map<UploaderItem, string>();
watch(() => props.items, () => {
for (const item of props.items) {
if (!objectUrlMap.has(item)) {
objectUrlMap.set(item, URL.createObjectURL(item.file));
}
}
for (const [item, url] of objectUrlMap.entries()) {
if (!props.items.includes(item)) {
URL.revokeObjectURL(url);
objectUrlMap.delete(item);
}
}
}, { immediate: true, deep: true });
onUnmounted(() => {
for (const url of objectUrlMap.values()) {
URL.revokeObjectURL(url);
}
objectUrlMap.clear();
});
//#endregion
function getUploadNameParts(item: UploaderItem): {
baseName: string;
extension: string | null;
@@ -98,8 +125,23 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
emit('showMenuViaContextmenu', item, ev);
}
function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
// TODO: preview when item is image
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
const contents = props.items.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video')).map(item => ({
id: item.id,
type: (item.file.type.startsWith('video') ? 'video' as const : 'image' as const),
url: objectUrlMap.get(item)!,
thumbnailUrl: item.thumbnail,
filename: getUploadName(item),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(content => content.id === item.id),
contents: contents,
}, {
closed: () => dispose(),
});
}
}
</script>
@@ -37,29 +37,41 @@ const observer = new ResizeObserver((entries) => {
</script>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { onBeforeUnmount, useTemplateRef, watch } from 'vue';
const props = withDefaults(defineProps<Props>(), {
minScale: 0,
});
const content = ref<HTMLSpanElement>();
const content = useTemplateRef('content');
function unobserve(el: HTMLSpanElement | null) {
if (el != null) {
delete (el as any)[contentSymbol];
observer.unobserve(el);
if (el.parentElement) {
observer.unobserve(el.parentElement);
}
}
}
function observe(el: HTMLSpanElement | null) {
if (el != null) {
(el as any)[contentSymbol] = props;
observer.observe(el);
if (el.parentElement) {
observer.observe(el.parentElement);
}
}
}
watch(content, (value, oldValue) => {
if (oldValue != null) {
delete (oldValue as any)[contentSymbol];
observer.unobserve(oldValue);
if (oldValue.parentElement) {
observer.unobserve(oldValue.parentElement);
}
}
if (value != null) {
(value as any)[contentSymbol] = props;
observer.observe(value);
if (value.parentElement) {
observer.observe(value.parentElement);
}
}
unobserve(oldValue);
observe(value);
});
onBeforeUnmount(() => {
unobserve(content.value);
});
</script>
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, toRefs, useTemplateRef, watch } from 'vue';
import type { DataSource, GridSetting, GridState, Size } from '@/components/grid/grid.js';
import type { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
import type { GridContext, GridEvent } from '@/components/grid/grid-event.js';
@@ -119,6 +119,10 @@ const { data } = toRefs(props);
* 子コンポーネント -> gridのイベントでは原則使用せず{@link emit}を使用する
*/
const bus = new GridEventEmitter();
/**
* {@link resizeObserver}が発行したまだ実行されていない{@link setTimeout}のIDアンマウント時にまとめて破棄するために保持する
*/
const resizeTimeoutIds = new Set<number>();
/**
* テーブルコンポーネントのリサイズイベントを監視するための{@link ResizeObserver}
* 表示切替を検知しサイズの再計算要求を発行するために使用するマウント時にコンテンツが表示されていない場合初手のサイズの自動計算が正常に働かないため
@@ -128,7 +132,13 @@ const bus = new GridEventEmitter();
*
* @see {@link onResize}
*/
const resizeObserver = new ResizeObserver((entries) => window.setTimeout(() => onResize(entries)));
const resizeObserver = new ResizeObserver((entries) => {
const timeoutId = window.setTimeout(() => {
resizeTimeoutIds.delete(timeoutId);
onResize(entries);
});
resizeTimeoutIds.add(timeoutId);
});
const rootEl = useTemplateRef('rootEl');
/**
@@ -1265,6 +1275,18 @@ onMounted(() => {
refreshData();
});
onUnmounted(() => {
resizeObserver.disconnect();
for (const timeoutId of resizeTimeoutIds) {
window.clearTimeout(timeoutId);
}
resizeTimeoutIds.clear();
// window
unregisterMouseMove();
unregisterMouseUp();
});
</script>
<style module lang="scss">
@@ -4,6 +4,7 @@
*/
import { ref, readonly, computed } from 'vue';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
const time = ref(Date.now());
@@ -29,6 +30,6 @@ export function useLowresTime() {
return computed(() => Math.max(time.value, now));
}
window.setInterval(() => {
createVisibilityAwareInterval(() => {
time.value = Date.now();
}, TIME_UPDATE_INTERVAL);
@@ -6,6 +6,7 @@
import { onUnmounted, reactive } from 'vue';
import * as Misskey from 'misskey-js';
import { EventEmitter } from 'eventemitter3';
import { createVisibilityAwareInterval } from '@@/js/interval.js';
import type { Reactive } from 'vue';
import type { NoteUpdatedEvent } from 'misskey-js/streaming.types.js';
import { useStream } from '@/stream.js';
@@ -68,7 +69,8 @@ const POLLING_INTERVAL =
prefer.s.pollingInterval === 3 ? MIN_POLLING_INTERVAL :
MIN_POLLING_INTERVAL;
window.setInterval(() => {
// documentが非表示の間はポーリングを停止する
createVisibilityAwareInterval(() => {
const ids = [...pollingQueue.entries()]
.filter(([k, v]) => Date.now() - v.lastAddedAt < 1000 * 60 * 5) // 追加されてから一定時間経過したものは省く
.map(([k, v]) => k)
@@ -76,7 +78,6 @@ window.setInterval(() => {
.slice(0, CAPTURE_MAX);
if (ids.length === 0) return;
if (window.document.hidden) return;
// まとめてリクエストするのではなく、個別にHTTPリクエスト投げてCDNにキャッシュさせた方がサーバーの負荷低減には良いかもしれない?
misskeyApi('notes/show-partial-bulk', {
@@ -29,7 +29,7 @@ export const clickAnimeDirective = {
target.addEventListener('mouseleave', () => {
target.classList.remove('_anime_bounce_ready');
});
}, { once: true, signal: abortController.signal });
}, { signal: abortController.signal });
el.addEventListener('click', () => {

Some files were not shown because too many files have changed in this diff Show More