Compare commits

...

41 Commits

Author SHA1 Message Date
かっこかり 038623325c Revert "fix: OTel除去 (#17812)"
This reverts commit 6dac6f7928.
2026-07-29 09:40:14 +09:00
おさむのひと 6f579b6854 chore: docker_example.ymlのsentryForBackend更新が漏れていたのを追記 (#17817) 2026-07-28 21:25:08 +09:00
github-actions[bot] d600525bb2 Bump version to 2026.7.0-rc.0 2026-07-28 10:21:11 +00:00
おさむのひと 6dac6f7928 fix: OTel除去 (#17812) 2026-07-28 19:15:44 +09:00
かっこかり 318af2e1ff fix(frontend): follow-up of #17811 (#17814) 2026-07-28 17:35:33 +09:00
かっこかり a44e419d5a fix(frontend): follow-up of 2509a28 (#17815) 2026-07-28 17:35:18 +09:00
かっこかり 2509a28813 Merge commit from fork 2026-07-28 11:11:56 +09:00
おさむのひと a12ee2e5d7 Merge commit from fork 2026-07-28 11:09:37 +09:00
かっこかり 703aa2360b enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように (#17811)
* enhance(frontend): UploaderItemsの詳細メニューからもプレビューを起動できるように

* clean up
2026-07-27 18:34:31 +09:00
かっこかり cb58ff32a4 fix(gh): check-misskey-js-autogenが失敗する問題を修正 (#17809)
* fix(gh): check-misskey-js-autogenが失敗する問題を修正

* fix comment

* fix
2026-07-27 16:48:36 +09:00
かっこかり f6979333d4 docs: Update CHANGELOG.md to include about js-yaml changes (#17805) 2026-07-27 00:14:34 +09:00
かっこかり e897255e71 fix 2026-07-25 21:43:41 +09:00
syuilo daf2a85539 Update CHANGELOG.md 2026-07-25 21:37:20 +09:00
chocolate-pie 1a59ec20e3 Merge commit from fork
* fix: Fix improper authorization in `admin/reset-password`

* Apply suggestions from code review

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>

* fix: improper authorization in `admin/unset-mfa`

* fix

---------

Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
2026-07-25 21:34:46 +09:00
4ster1sk 3e31e59bcd fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように (#17802)
* test(e2e): フォロー中のチャンネルに再度フォローAPIを叩いた場合のテストを追加

* fix(backend): フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように

* docs(changelog): update changelog
2026-07-25 21:09:12 +09:00
anatawa12 982d4905c0 use UPSERT instead of select for hashtag statistics update (#17795)
* feat: set defaults for hashtag table

* chore: use upsert to update hashtag table

* fix: query runner not cleaned up

* fix: query runner not cleaned up correctly

* chore: replace await using => try-finally
2026-07-25 19:48:20 +09:00
renovate[bot] 7ecce0901c fix(deps): update dependency js-yaml to v5.2.2 [security] (#17797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 16:07:49 +09:00
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
96 changed files with 1411 additions and 831 deletions
+6
View File
@@ -20,9 +20,15 @@ reviews:
instructions: |
【レビュー対象外】
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。
【特別な対応を要するレビュー】
- プルリクエストで変更されていない部分において、プルリクエストのタイトルや説明文に照らしてスコープ外であるが修正すべき点を見つけた場合は、このプルリクエストで修正させるのではなく、別のプルリクエストを開いてそれを修正するように指示してください。\
なお、当該プルリクエストで行が追加/削除されたことによりスコープ外の問題が起こりうる場合は、例外的にこのプルリクエストで修正させてください。
【注力してほしい観点】
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
- プルリクエストのタイトルや説明文に照らしてスコープ外の変更が含まれていないかをレビューしてください。なお、明らかにスコープ外であると確信できるもの(例: 全く関係ないファイルを編集している等)のみを対象とし、スコープ外かどうかの判断に困る場合は指摘しないでください。\
スコープ外の変更が含まれていた場合は指摘し、その部分について、このプルリクエストに含めずに別のプルリクエストを開いて変更するように指示してください。
path_filters:
- "!CHANGELOG.md"
- "!**/__snapshots__/**"
+23
View File
@@ -173,8 +173,31 @@ id: 'aidx'
#sentryForBackend:
# enableNodeProfiling: true
# # Specify Sentry integration names to disable individual auto-instrumentation.
# # The names are integration .name values, not factory function names.
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
# # Postgres ... DB queries (when pg is externalized)
# # Redis ... ioredis commands
# # Fastify ... inbound HTTP routes
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
# # NodeFetch ... fetch/undici requests
# disabledIntegrations: ['Postgres']
# options:
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
# # To intentionally propagate distributed traces to trusted internal services,
# # list only those internal URL patterns here.
# # Avoid broad patterns that match remote servers unless you intentionally
# # want to restore Sentry's legacy behavior of propagating traces to all
# # outbound HTTP requests.
# #tracePropagationTargets: []
# # Internal allowlist example:
# #tracePropagationTargets:
# # - 'internal-service.example'
#sentryForFrontend:
# vueIntegration:
+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
@@ -0,0 +1,73 @@
name: Check Misskey JS autogen (comment)
on:
workflow_run:
types: [completed]
workflows:
- Check Misskey JS autogen # check-misskey-js-autogen.yml
jobs:
comment-misskey-js-autogen:
runs-on: ubuntu-latest
# No code is cloned or executed here, so it is safe to hold pull-requests: write
if: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions:
actions: read
pull-requests: write
steps:
# The result is missing when the run failed before the check job (e.g. the PR does not build), so do not fail here
- name: Download result
id: download-result
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: misskey-js-autogen-result
path: result
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
# The artifact comes from a run that built fork code, so validate it before using
- name: Load result
id: load-result
if: steps.download-result.outcome == 'success'
run: |
pr_number="$(cat result/pr-number)"
changes="$(cat result/changes)"
case "$pr_number" in
''|*[!0-9]*) echo "invalid pr number"; exit 1;;
esac
case "$changes" in
true|false) ;;
*) echo "invalid result"; exit 1;;
esac
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
echo "changes=$changes" >> "$GITHUB_OUTPUT"
- name: send message
if: steps.load-result.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.load-result.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-result.outputs.pr-number }}
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
+20 -29
View File
@@ -1,7 +1,8 @@
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
name: Check Misskey JS autogen
on:
pull_request_target:
pull_request:
branches:
- master
- develop
@@ -10,7 +11,6 @@ on:
- packages/backend/**
jobs:
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
generate-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -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
@@ -58,7 +58,6 @@ jobs:
name: generated-misskey-js
path: packages/misskey-js/generator/built/autogen
# pull_request_target safety: permissions: read-all, and no user codes are executed
get-actual-misskey-js:
runs-on: ubuntu-latest
permissions:
@@ -66,7 +65,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
@@ -78,12 +77,11 @@ jobs:
name: actual-misskey-js
path: packages/misskey-js/src/autogen
# pull_request_target safety: nothing is cloned from repository
comment-misskey-js-autogen:
check-misskey-js-autogen:
runs-on: ubuntu-latest
needs: [generate-misskey-js, get-actual-misskey-js]
permissions:
pull-requests: write
contents: read
steps:
- name: download generated-misskey-js
uses: actions/download-artifact@v8
@@ -111,28 +109,21 @@ jobs:
- name: Print full diff
run: cat ./misskey-js.diff
- name: send message
if: steps.check-changes.outputs.changes == 'true'
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: check-misskey-js-autogen
message: |-
Thank you for sending us a great Pull Request! 👍
Please regenerate misskey-js type definitions! 🙏
# A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
- name: Save result
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
CHANGES: ${{ steps.check-changes.outputs.changes }}
run: |
mkdir -p result
echo "$PR_NUMBER" > result/pr-number
echo "$CHANGES" > result/changes
example:
```sh
pnpm run build-misskey-js-with-types
```
- name: send message
if: steps.check-changes.outputs.changes == 'false'
uses: thollander/actions-comment-pull-request@v3
- name: Upload result
uses: actions/upload-artifact@v7
with:
comment-tag: check-misskey-js-autogen
mode: delete
message: "Thank you!"
create_if_not_exists: false
name: misskey-js-autogen-result
path: result
- name: Make failure if changes are detected
if: steps.check-changes.outputs.changes == 'true'
@@ -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'
+9 -2
View File
@@ -12,16 +12,19 @@
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
- Docker Image は Node.js 26.4.0-trixie に更新されています。
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
### General
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
- Enhance: 依存関係の更新
### Client
- 2025.4.0 以前の設定情報の移行処理が削除されました
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
- Enhance: 画像ビューワーを刷新・動画プレイヤーを統合
- 操作性の改善
- ビューワーとMisskeyの各種機能との統合を強化
- パフォーマンスの向上
@@ -29,8 +32,9 @@
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
- Enhance: タイムラインの読み込みパフォーマンスを改善
- Enhance: ファイルアップロード前にプレビューできるように
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
- Enhance: 翻訳の更新
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
@@ -70,6 +74,9 @@
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
- Fix: セキュリティに関する修正
## 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"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0-beta.3",
"version": "2026.7.0-rc.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -57,8 +57,8 @@
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "5.2.1",
"tar": "7.5.20"
"js-yaml": "5.2.2",
"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', () => {
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class HashtagTableDefaults1784899839024 {
name = 'HashtagTableDefaults1784899839024'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" SET DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" SET DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "attachedUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedRemoteUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedLocalUserIds" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "hashtag" ALTER COLUMN "mentionedUserIds" DROP DEFAULT`);
}
}
+2 -2
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",
@@ -193,7 +193,7 @@
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"pid-port": "2.1.1",
"rolldown": "1.1.5",
"simple-oauth2": "5.1.0",
@@ -13,6 +13,8 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import type { MiLocalUser } from '@/models/User.js';
import { RedisKVCache } from '@/misc/cache.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
@Injectable()
export class ChannelFollowingService implements OnModuleInit {
@@ -96,11 +98,18 @@ export class ChannelFollowingService implements OnModuleInit {
requestUser: MiLocalUser,
targetChannel: MiChannel,
): Promise<void> {
await this.channelFollowingsRepository.insert({
id: this.idService.gen(),
followerId: requestUser.id,
followeeId: targetChannel.id,
});
try {
await this.channelFollowingsRepository.insert({
id: this.idService.gen(),
followerId: requestUser.id,
followeeId: targetChannel.id,
});
} catch (e) {
if (isDuplicateKeyValueError(e)) {
throw new IdentifiableError('6e335e39-0203-4418-a936-b3f2dc987845', 'already following');
}
throw e;
}
this.globalEventService.publishInternalEvent('followChannel', {
userId: requestUser.id,
+90 -114
View File
@@ -16,11 +16,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js';
const logger = new Logger('hashtag/create');
type AttachedOrMentioned = 'attached' | 'mentioned';
type UpdatingHashtagColumn = {
totalUserIds: keyof MiHashtag & `${AttachedOrMentioned}UserIds`,
totalUsersCount: keyof MiHashtag & `${AttachedOrMentioned}UsersCount`,
localUserIds: keyof MiHashtag & `${AttachedOrMentioned}LocalUserIds`,
localUsersCount: keyof MiHashtag & `${AttachedOrMentioned}LocalUsersCount`,
remoteUserIds: keyof MiHashtag & `${AttachedOrMentioned}RemoteUserIds`,
remoteUsersCount: keyof MiHashtag & `${AttachedOrMentioned}RemoteUsersCount`,
};
@Injectable()
export class HashtagService {
constructor(
@@ -68,126 +77,93 @@ export class HashtagService {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
{
const index = await this.hashtagsRepository.findOneBy({ name: tag });
const column: UpdatingHashtagColumn = isUserAttached ? {
totalUserIds: 'attachedUserIds',
totalUsersCount: 'attachedUsersCount',
localUserIds: 'attachedLocalUserIds',
localUsersCount: 'attachedLocalUsersCount',
remoteUserIds: 'attachedRemoteUserIds',
remoteUsersCount: 'attachedRemoteUsersCount',
} : {
totalUserIds: 'mentionedUserIds',
totalUsersCount: 'mentionedUsersCount',
localUserIds: 'mentionedLocalUserIds',
localUsersCount: 'mentionedLocalUsersCount',
remoteUserIds: 'mentionedRemoteUserIds',
remoteUsersCount: 'mentionedRemoteUsersCount',
};
if (index == null && inc) {
try {
if (isUserAttached) {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
return;
} catch (err) {
if (isDuplicateKeyValueError(err)) {
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
} else {
throw err;
}
}
}
if (inc) {
await this.#incrementHashTag(user, tag, column);
} else {
await this.#decrementHashTag(user, tag, column);
}
}
async #incrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`INSERT into "hashtag"("id", "name", "${totalUserIds}", "${totalUsersCount}", "${localOrRemoteUserIds}",
"${localOrRemoteUserCount}")
VALUES ($3, $1, ARRAY [$2], 1, ARRAY [$2], 1)
ON CONFLICT ("name")
DO UPDATE SET "${totalUserIds}" = ${appendUserIdIfNotExists(totalUserIds)},
"${totalUsersCount}" = ${incrementCountIfNotExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = ${appendUserIdIfNotExists(localOrRemoteUserIds)},
"${localOrRemoteUserCount}" = ${incrementCountIfNotExists(localOrRemoteUserIds, localOrRemoteUserCount)}`,
[tag, user.id, this.idService.gen()],
);
} finally {
await runner.release();
}
await this.db.transaction(async transactionalEntityManager => {
const transactionalHashtagRepository = transactionalEntityManager
.getRepository(MiHashtag);
function appendUserIdIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN array_append("hashtag"."${userIds}", $2) ELSE "hashtag"."${userIds}" END`;
}
const index = await transactionalHashtagRepository
.createQueryBuilder()
.setLock('pessimistic_write')
.where('name = :name', { name: tag })
.getOne();
function incrementCountIfNotExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN NOT ("hashtag"."${userIds}" @> ARRAY[$2 ::varchar]) THEN "hashtag"."${userCount}" + 1 ELSE "hashtag"."${userCount}" END`;
}
}
if (index == null) return;
async #decrementHashTag(
user: { id: MiUser['id']; host: MiUser['host']; },
tag: string,
columns: UpdatingHashtagColumn,
) {
const isLocal = this.userEntityService.isLocalUser(user);
const { totalUserIds, totalUsersCount } = columns;
const localOrRemoteUserIds = isLocal ? columns.localUserIds : columns.remoteUserIds;
const localOrRemoteUserCount = isLocal ? columns.localUsersCount : columns.remoteUsersCount;
const set = {} as any;
const runner = this.db.createQueryRunner('master');
try {
await runner.query(
`UPDATE "hashtag"
SET "${totalUserIds}" = array_remove("${totalUserIds}", $2),
"${totalUsersCount}" = ${decrementIfExists(totalUserIds, totalUsersCount)},
"${localOrRemoteUserIds}" = array_remove("${localOrRemoteUserIds}", $2),
"${localOrRemoteUserCount}" = ${decrementIfExists(localOrRemoteUserIds, localOrRemoteUserCount)}
WHERE "name" = $1`,
[tag, user.id],
);
} finally {
await runner.release();
}
if (isUserAttached) {
if (inc) {
// 自分が初めてこのタグを使ったなら
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
}
} else {
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
if (this.userEntityService.isLocalUser(user)) {
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
} else {
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
}
}
} else {
// 自分が初めてこのタグを使ったなら
if (!index.mentionedUserIds.some(id => id === user.id)) {
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
}
// 自分が(ローカル内で)初めてこのタグを使ったなら
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
}
// 自分が(リモートで)初めてこのタグを使ったなら
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
}
}
if (Object.keys(set).length > 0) {
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
});
function decrementIfExists(userIds: keyof MiHashtag & `${string}UserIds`, userCount: keyof MiHashtag & `${string}UsersCount`): string {
return `CASE WHEN ("${userIds}" @> ARRAY[$2]) THEN "${userCount}" - 1 ELSE "${userCount}" END`;
}
}
@bindThis
+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);
}
}
}
+7 -3
View File
@@ -52,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;
};
/**
*
* 使
+6
View File
@@ -20,6 +20,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedUserIds: MiUser['id'][];
@@ -32,6 +33,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedLocalUserIds: MiUser['id'][];
@@ -44,6 +46,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public mentionedRemoteUserIds: MiUser['id'][];
@@ -56,6 +59,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedUserIds: MiUser['id'][];
@@ -68,6 +72,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedLocalUserIds: MiUser['id'][];
@@ -80,6 +85,7 @@ export class MiHashtag {
@Column({
...id(),
default: [],
array: true,
})
public attachedRemoteUserIds: MiUser['id'][];
@@ -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);
}
@@ -10,6 +10,7 @@ import { ApiError } from '@/server/api/error.js';
import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
@@ -25,10 +26,10 @@ export const meta = {
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
cannotResetPasswordOfRootUser: {
message: 'Cannot reset password of the root user.',
code: 'CANNOT_RESET_PASSWORD_OF_ROOT_USER',
id: 'f28fc207-42ca-44c7-a577-44b4f0ec5999',
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
},
@@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -75,8 +77,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (this.serverSettings.rootUserId === user.id) {
throw new ApiError(meta.errors.cannotResetPasswordOfRootUser);
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
const passwd = secureRndstr(8);
@@ -11,6 +11,7 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
@@ -26,6 +27,11 @@ export const meta = {
code: 'NO_SUCH_USER',
id: 'ccafc7fe-5074-4edd-9dc0-8ef9ef6a701d',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'cda8f8ce-89a6-4f92-8055-33bbe0c1464d',
},
},
} as const;
@@ -46,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -55,6 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (await this.roleService.isAdministrator(user) && me.id !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.db.transaction(async (transactionalEntityManager) => {
// パスキーを全て削除
await transactionalEntityManager.delete(MiUserSecurityKey, { userId: user.id });
@@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -25,6 +26,11 @@ export const meta = {
code: 'NO_SUCH_CHANNEL',
id: 'c0031718-d573-4e85-928e-10039f1fbb68',
},
alreadyFollowing: {
message: 'You are already following that channel.',
code: 'ALREADY_FOLLOWING',
id: '7db31665-651e-40c1-8e6e-28e9ad829a2d',
},
},
} as const;
@@ -52,7 +58,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel);
}
await this.channelFollowingService.follow(me, channel);
try {
await this.channelFollowingService.follow(me, channel);
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '6e335e39-0203-4418-a936-b3f2dc987845') throw new ApiError(meta.errors.alreadyFollowing);
}
throw e;
}
});
}
}
@@ -7,8 +7,11 @@ import Parser from 'rss-parser';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { ApiError } from '../error.js';
const rssParser = new Parser();
const MAX_URL_LENGTH = 8192;
const MAX_RESPONSE_SIZE = 1024 * 1024;
const MAX_CONCURRENT_REQUESTS = 32;
export const meta = {
tags: ['meta'],
@@ -17,6 +20,34 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 3,
limit: {
duration: 60 * 1000,
max: 300,
},
errors: {
invalidUrl: {
message: 'Invalid URL.',
code: 'INVALID_URL',
id: '89b7ee05-ccfc-4bdd-9b13-61172fd1e06c',
httpStatusCode: 400,
},
fetchRssFailed: {
message: 'Failed to fetch RSS.',
code: 'FETCH_RSS_FAILED',
id: '8db5d3d8-31d7-452f-b0cc-ca3b8925de12',
kind: 'server',
httpStatusCode: 422,
},
fetchRssUnavailable: {
message: 'RSS fetching is temporarily unavailable.',
code: 'FETCH_RSS_UNAVAILABLE',
id: '91e6ff44-c63f-4725-9ad0-b7a40d7f7655',
kind: 'server',
httpStatusCode: 503,
},
},
res: {
type: 'object',
properties: {
@@ -215,21 +246,84 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
private readonly inFlightRequests = new Map<string, Promise<Awaited<ReturnType<Parser['parseString']>>>>();
private activeRequestCount = 0;
constructor(
private httpRequestService: HttpRequestService,
) {
super(meta, paramDef, async (ps, me) => {
const res = await this.httpRequestService.send(ps.url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
});
super(meta, paramDef, async (ps) => {
const url = this.normalizeUrl(ps.url);
const inFlightRequest = this.inFlightRequests.get(url);
if (inFlightRequest != null) {
return await inFlightRequest;
}
const text = await res.text();
if (this.activeRequestCount >= MAX_CONCURRENT_REQUESTS) {
throw new ApiError(meta.errors.fetchRssUnavailable);
}
return rssParser.parseString(text);
this.activeRequestCount++;
const request = this.fetchRss(url)
.catch(() => {
throw new ApiError(meta.errors.fetchRssFailed);
})
.finally(() => {
this.inFlightRequests.delete(url);
this.activeRequestCount--;
});
this.inFlightRequests.set(url, request);
return await request;
});
}
private normalizeUrl(input: string): string {
if (input.length === 0 || input.length > MAX_URL_LENGTH) {
throw new ApiError(meta.errors.invalidUrl);
}
let url: URL;
try {
url = new URL(input);
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== ''
) {
throw new ApiError(meta.errors.invalidUrl);
}
url.hash = '';
return url.href;
}
private async fetchRss(url: string): Promise<Awaited<ReturnType<Parser['parseString']>>> {
const res = await this.httpRequestService.send(url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, */*',
},
timeout: 5000,
size: MAX_RESPONSE_SIZE,
});
const finalUrl = new URL(res.url);
if (finalUrl.protocol !== 'http:' && finalUrl.protocol !== 'https:') {
throw new Error('Invalid final URL protocol');
}
const text = await res.text();
const rssParser = new Parser({
xml2js: {
async: true,
},
});
return await rssParser.parseString(text);
}
}
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { api, castAsError, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('Channel', () => {
let alice: misskey.entities.SignupResponse;
beforeAll(async () => {
alice = await signup({ username: 'alice' });
});
describe('Follow', () => {
let channel: misskey.entities.ChannelsCreateResponse;
beforeAll(async () => {
const res = await api('channels/create', { name: 'follow-test-channel' }, alice);
channel = res.body;
});
test('フォローしているチャンネルを再度フォローするとALREADY_FOLLOWINGエラーになる', async () => {
const res1 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res1.status, 204);
const res2 = await api('channels/follow', { channelId: channel.id }, alice);
assert.strictEqual(res2.status, 400);
assert.strictEqual(castAsError(res2.body as any).error.code, 'ALREADY_FOLLOWING');
});
});
});
@@ -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');
@@ -0,0 +1,192 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import FetchRssEndpoint, { meta } from '@/server/api/endpoints/fetch-rss.js';
import { ApiError } from '@/server/api/error.js';
import type { Mocked } from 'vitest';
import type { Response } from 'node-fetch';
const rssParserMocks = vi.hoisted(() => ({
constructor: vi.fn(),
parseString: vi.fn(),
}));
vi.mock('rss-parser', () => ({
default: class {
constructor(options: unknown) {
rssParserMocks.constructor(options);
}
public parseString(input: string) {
return rssParserMocks.parseString(input);
}
},
}));
const RSS = '<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function response(url: string, text = RSS): Response {
return {
url,
text: vi.fn().mockResolvedValue(text),
} as unknown as Response;
}
describe('fetch-rss endpoint', () => {
let httpRequestService: Mocked<HttpRequestService>;
let endpoint: FetchRssEndpoint;
beforeEach(() => {
rssParserMocks.constructor.mockReset();
rssParserMocks.parseString.mockReset();
rssParserMocks.parseString.mockResolvedValue({ items: [] });
httpRequestService = {
send: vi.fn(),
} as unknown as Mocked<HttpRequestService>;
endpoint = new FetchRssEndpoint(httpRequestService);
});
async function exec(url: string) {
return await endpoint.exec({ url }, null, null);
}
async function expectApiError(promise: Promise<unknown>, code: string, status: number) {
await expect(promise).rejects.toMatchObject({
code,
httpStatusCode: status,
info: undefined,
});
}
test.each([
'',
'not a URL',
'ftp://example.com/feed.xml',
`https://example.com/${'a'.repeat(8192)}`,
])('rejects invalid URL: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test.each([
'https://user@example.com/feed.xml',
'https://user:password@example.com/feed.xml',
])('rejects URL containing credentials: %s', async (url) => {
await expectApiError(exec(url), 'INVALID_URL', 400);
expect(httpRequestService.send).not.toHaveBeenCalled();
});
test('does not expose details from internal errors', async () => {
httpRequestService.send.mockRejectedValue(new Error('secret upstream details'));
const promise = exec('https://example.com/feed.xml');
await expectApiError(promise, 'FETCH_RSS_FAILED', 422);
await expect(promise).rejects.not.toMatchObject({
message: expect.stringContaining('secret upstream details'),
});
});
test('passes the 1 MiB response limit to HttpRequestService', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledWith('https://example.com/feed.xml', expect.objectContaining({
size: 1024 * 1024,
}));
});
test('creates an asynchronous RSS parser for every request', async () => {
httpRequestService.send
.mockResolvedValueOnce(response('https://example.com/first.xml'))
.mockResolvedValueOnce(response('https://example.com/second.xml'));
await exec('https://example.com/first.xml');
await exec('https://example.com/second.xml');
expect(rssParserMocks.constructor).toHaveBeenCalledTimes(2);
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(1, { xml2js: { async: true } });
expect(rssParserMocks.constructor).toHaveBeenNthCalledWith(2, { xml2js: { async: true } });
});
test('rejects a non-HTTP final URL without exposing it', async () => {
httpRequestService.send.mockResolvedValue(response('file:///secret/feed.xml'));
await expectApiError(exec('https://example.com/feed.xml'), 'FETCH_RSS_FAILED', 422);
});
test('shares an in-flight request for the same normalized URL', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const first = exec('HTTPS://EXAMPLE.COM:443/feed.xml#first');
const second = exec('https://example.com/feed.xml#second');
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
pending.resolve(response('https://example.com/feed.xml'));
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
expect(httpRequestService.send).toHaveBeenCalledTimes(1);
});
test('limits concurrent requests for different URLs to 32', async () => {
const pending = deferred<Response>();
httpRequestService.send.mockReturnValue(pending.promise);
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
await expectApiError(exec('https://example.com/overflow.xml'), 'FETCH_RSS_UNAVAILABLE', 503);
expect(httpRequestService.send).toHaveBeenCalledTimes(32);
pending.resolve(response('https://example.com/feed.xml'));
await Promise.all(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/after.xml'));
await expect(exec('https://example.com/after.xml')).resolves.toBeDefined();
});
test('cleans up the in-flight request and concurrency slot after success', async () => {
httpRequestService.send.mockResolvedValue(response('https://example.com/feed.xml'));
await exec('https://example.com/feed.xml');
await exec('https://example.com/feed.xml');
expect(httpRequestService.send).toHaveBeenCalledTimes(2);
});
test('cleans up the in-flight request and concurrency slot after failure', async () => {
httpRequestService.send.mockRejectedValue(new Error('upstream failed'));
const requests = Array.from({ length: 32 }, (_, i) => exec(`https://example.com/${i}.xml`));
await Promise.allSettled(requests);
httpRequestService.send.mockResolvedValue(response('https://example.com/0.xml'));
await expect(exec('https://example.com/0.xml')).resolves.toBeDefined();
expect(httpRequestService.send).toHaveBeenCalledTimes(33);
});
test('has the expected rate limit metadata', () => {
expect(meta.limit).toEqual({
duration: 60 * 1000,
max: 300,
});
});
test('uses only the declared structured API errors', () => {
expect(new ApiError(meta.errors.invalidUrl)).toMatchObject({ code: 'INVALID_URL', httpStatusCode: 400 });
expect(new ApiError(meta.errors.fetchRssFailed)).toMatchObject({ code: 'FETCH_RSS_FAILED', httpStatusCode: 422 });
expect(new ApiError(meta.errors.fetchRssUnavailable)).toMatchObject({ code: 'FETCH_RSS_UNAVAILABLE', httpStatusCode: 503 });
});
});
-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;
}
@@ -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",
@@ -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>
@@ -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;
@@ -193,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 {
@@ -202,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,127 +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 && animating && !animatingOut,
[$style.leave]: animatingOut,
[$style.animating]: animating,
}]"
@animationend.self="onAnimationEnd"
@animationcancel.self="onAnimationEnd"
>
<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, watch } from 'vue';
const props = defineProps<{
animatingIn?: boolean;
animatingOut?: boolean;
}>();
const rootEl = useTemplateRef('rootEl');
const innerEl = useTemplateRef('innerEl');
const animating = ref(false);
watch([() => props.animatingIn, () => props.animatingOut], ([animatingIn, animatingOut]) => {
if (animatingIn === true || animatingOut === true) animating.value = true;
}, { immediate: true });
function onAnimationEnd() {
// enter
if (props.animatingOut === true) return;
animating.value = 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>
@@ -51,11 +51,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } 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';
import type { Content } from '@/components/MkLightbox.item.vue';
const props = defineProps<{
items: UploaderItem[];
@@ -98,8 +100,25 @@ 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<Content>(item => ({
id: item.id,
type: (item.file.type.startsWith('video') ? 'video' : 'image'),
url: item.objectUrl,
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>
@@ -16,6 +16,7 @@ import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import type { Content } from '@/components/MkLightbox.item.vue';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
@@ -74,6 +75,7 @@ export type UploaderItem = {
compressedSize?: number | null;
preprocessedFile?: Blob | null;
file: File;
objectUrl: string;
watermarkPreset: WatermarkPreset | null;
watermarkLayers: WatermarkLayers | null;
imageFrameParams: ImageFrameParams | null;
@@ -133,12 +135,13 @@ export function useUploader(options: {
const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
const watermarkPreset = uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? (prefer.s.watermarkPresets.find(p => p.id === prefer.s.defaultWatermarkPresetId) ?? null) : null;
const objectUrl = window.URL.createObjectURL(file);
items.value.push({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
suffix: '',
progress: null,
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null,
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? objectUrl : null,
preprocessing: false,
preprocessProgress: null,
uploading: false,
@@ -150,6 +153,7 @@ export function useUploader(options: {
watermarkLayers: watermarkPreset?.layers ?? null,
imageFrameParams: null,
file: markRaw(file),
objectUrl,
});
const reactiveItem = items.value.at(-1)!;
preprocess(reactiveItem).then(() => {
@@ -163,8 +167,24 @@ export function useUploader(options: {
}
}
function removeItem(item: UploaderItem) {
function revokeItemObjectUrls(item: UploaderItem) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
URL.revokeObjectURL(item.objectUrl);
}
function createItemObjectUrl(item: UploaderItem, file: Blob | File): string {
revokeItemObjectUrls(item);
return window.URL.createObjectURL(file);
}
function updateItemObjectUrls(item: UploaderItem, file: Blob | File) {
const newObjectUrl = createItemObjectUrl(item, file);
item.objectUrl = newObjectUrl;
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null;
}
function removeItem(item: UploaderItem) {
revokeItemObjectUrls(item);
items.value.splice(items.value.indexOf(item), 1);
}
@@ -214,7 +234,35 @@ export function useUploader(options: {
closed: () => dispose(),
});
},
}, {
});
if (item.file.type.startsWith('image/') || item.file.type.startsWith('video/')) {
menu.push({
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: async () => {
const contents = items.value
.filter(item => item.file.type.startsWith('image/') || item.file.type.startsWith('video/'))
.map<Content>(item => ({
id: item.id,
type: item.file.type.startsWith('video/') ? 'video' : 'image',
url: item.objectUrl,
thumbnail: item.thumbnail,
filename: getUploadName(item),
caption: item.caption ?? null,
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
defaultIndex: contents.findIndex(x => x.id === item.id),
contents,
}, {
closed: () => dispose(),
});
},
});
}
menu.push({
type: 'divider',
});
}
@@ -235,11 +283,12 @@ export function useUploader(options: {
text: i18n.ts.cropImage,
action: async () => {
const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
const newObjectUrl = createItemObjectUrl(item, cropped);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(cropped),
thumbnail: window.URL.createObjectURL(cropped),
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(cropped.type) ? newObjectUrl : null,
objectUrl: newObjectUrl,
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
@@ -260,11 +309,12 @@ export function useUploader(options: {
image: item.file,
}, {
ok: (file) => {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
const newObjectUrl = createItemObjectUrl(item, file);
items.value.splice(items.value.indexOf(item), 1, {
...item,
file: markRaw(file),
thumbnail: window.URL.createObjectURL(file),
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? newObjectUrl : null,
objectUrl: newObjectUrl,
});
const reactiveItem = items.value.find(x => x.id === item.id)!;
preprocess(reactiveItem).then(() => {
@@ -694,8 +744,7 @@ export function useUploader(options: {
imageBitmap.close();
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
updateItemObjectUrls(item, preprocessedFile);
item.preprocessedFile = markRaw(preprocessedFile);
}
@@ -754,14 +803,13 @@ export function useUploader(options: {
item.suffix = '';
}
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
updateItemObjectUrls(item, preprocessedFile);
item.preprocessedFile = markRaw(preprocessedFile);
}
function reset() {
for (const item of items.value) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
revokeItemObjectUrls(item);
}
abortAll();
@@ -548,6 +548,8 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
public renameProfile(name: string) {
this.profile.name = name;
// TODO: バックアップのキーが名前ベースであることを考えると名前=IDであるから、idも再生成する方が自然かもしれない
// (将来名前ではなくIDをキーにするようになったとした場合、名前を変えたのにバックアップのキーが変わらないことになってしまう)
this.save();
}
@@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { url as baseUrl } from '@@/js/config.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
import { shuffle } from '@/utility/shuffle.js';
@@ -53,7 +54,11 @@ const tick = () => {
if (props.shuffle) {
shuffle(feed.items);
}
items.value = feed.items;
items.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = new URL(item.link, baseUrl);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
});
+1 -45
View File
@@ -17,8 +17,6 @@ export type MisskeyEntity = {
id: string;
createdAt: string;
_shouldInsertAd_?: boolean;
_shouldAnimateIn_?: boolean;
_shouldAnimateOut_?: boolean;
};
type AbsEndpointType = {
@@ -109,8 +107,6 @@ export class Paginator<
private canFetchDetection: 'safe' | 'limit' | null = null;
private aheadQueue: T[] = [];
private useShallowRef: SRef;
private itemRemovalDelay: number | false;
private removalTimers = new Map<string, number>();
// 配列内の要素をどのような順序で並べるか
// newest: 新しいものが先頭 (default)
@@ -142,9 +138,6 @@ export class Paginator<
useShallowRef?: SRef;
// アイテム削除時にアニメーションを待つ時間 (ms)
itemRemovalDelay?: number | false;
canSearch?: boolean;
searchParamName?: keyof E['req'];
}) {
@@ -167,7 +160,6 @@ export class Paginator<
this.noPaging = props.noPaging ?? false;
this.offsetMode = props.offsetMode ?? false;
this.canSearch = props.canSearch ?? false;
this.itemRemovalDelay = props.itemRemovalDelay ?? false;
this.searchParamName = props.searchParamName ?? 'search';
this.getNewestId = this.getNewestId.bind(this);
@@ -199,7 +191,6 @@ export class Paginator<
}
public async init(): Promise<void> {
this.clearRemovalTimers();
this.items.value = [];
this.aheadQueue = [];
this.queuedAheadItemsCount.value = 0;
@@ -393,8 +384,6 @@ export class Paginator<
public prepend(item: T): void {
if (this.items.value.some(x => x.id === item.id)) return;
item._shouldAnimateIn_ = true;
item._shouldAnimateOut_ = false;
this.items.value.unshift(item);
this.trim(false);
if (this.useShallowRef) triggerRef(this.items);
@@ -410,9 +399,6 @@ export class Paginator<
public releaseQueue(): void {
if (this.aheadQueue.length === 0) return; // これやらないと余計なre-renderが走る
for (const item of this.aheadQueue) {
item._shouldAnimateIn_ = true;
}
this.unshiftItems(this.aheadQueue);
this.aheadQueue = [];
this.queuedAheadItemsCount.value = 0;
@@ -421,43 +407,13 @@ export class Paginator<
public removeItem(id: string): void {
// TODO: queueからも消す
if (this.itemRemovalDelay === false) {
const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) {
this.items.value.splice(index, 1);
if (this.useShallowRef) triggerRef(this.items);
}
return;
}
const index = this.items.value.findIndex(x => x.id === id);
if (index !== -1) {
this.items.value.splice(index, 1);
if (this.useShallowRef) triggerRef(this.items);
const item = this.items.value[index]!;
item._shouldAnimateOut_ = true;
if (this.useShallowRef) triggerRef(this.items);
if (this.removalTimers.has(id)) return;
this.removalTimers.set(id, window.setTimeout(() => {
this.removalTimers.delete(id);
const currentIndex = this.items.value.findIndex(x => x.id === id);
if (currentIndex !== -1) {
this.items.value.splice(currentIndex, 1);
if (this.useShallowRef) triggerRef(this.items);
}
}, this.itemRemovalDelay + 20)); // アニメーション終了からやや余裕をもたせる
}
}
private clearRemovalTimers(): void {
for (const timer of this.removalTimers.values()) {
window.clearTimeout(timer);
}
this.removalTimers.clear();
}
public updateItem(id: string, updater: (item: T) => T): void {
// TODO: queueのも更新
+5 -1
View File
@@ -81,7 +81,11 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items;
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = new URL(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
});
};
@@ -121,7 +121,11 @@ const tick = () => {
window.fetch(fetchEndpoint.value, {})
.then(res => res.json())
.then((feed: Misskey.entities.FetchRssResponse) => {
rawItems.value = feed.items;
rawItems.value = feed.items.filter((item) => {
if (!item.link) return false;
const itemUrl = new URL(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol);
});
fetching.value = false;
key.value++;
});
+1 -1
View File
@@ -38,6 +38,6 @@
"tsx": "4.23.0"
},
"dependencies": {
"js-yaml": "5.2.1"
"js-yaml": "5.2.2"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2026.7.0-beta.3",
"version": "2026.7.0-rc.0",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",
+9
View File
@@ -21879,6 +21879,15 @@ export interface operations {
'application/json': components['schemas']['Error'];
};
};
/** @description Too many requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
+28 -20
View File
@@ -24,11 +24,11 @@ importers:
specifier: 9.0.0
version: 9.0.0
js-yaml:
specifier: 5.2.1
version: 5.2.1
specifier: 5.2.2
version: 5.2.2
tar:
specifier: 7.5.20
version: 7.5.20
specifier: 7.5.21
version: 7.5.21
devDependencies:
'@eslint/js':
specifier: 9.39.5
@@ -192,8 +192,8 @@ importers:
specifier: 0.20.1
version: 0.20.1(@opentelemetry/api@1.9.1)
'@fastify/static':
specifier: 10.1.0
version: 10.1.0
specifier: 10.1.2
version: 10.1.2
'@kitajs/html':
specifier: 4.2.13
version: 4.2.13
@@ -592,8 +592,8 @@ importers:
specifier: 10.0.3
version: 10.0.3
js-yaml:
specifier: 5.2.1
version: 5.2.1
specifier: 5.2.2
version: 5.2.2
pid-port:
specifier: 2.1.1
version: 2.1.1
@@ -673,6 +673,9 @@ importers:
'@misskey-dev/emoji-data':
specifier: 17.0.3
version: 17.0.3
'@misskey-dev/tagcanvas-es':
specifier: 0.1.2
version: 0.1.2
'@sentry/vue':
specifier: 10.65.0
version: 10.65.0(vue@3.5.39(typescript@6.0.2))
@@ -1227,8 +1230,8 @@ importers:
packages/i18n:
dependencies:
js-yaml:
specifier: 5.2.1
version: 5.2.1
specifier: 5.2.2
version: 5.2.2
devDependencies:
'@types/node':
specifier: 26.1.1
@@ -1952,8 +1955,8 @@ packages:
'@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/static@10.1.0':
resolution: {integrity: sha512-iK/8TvRM/EgNOyQL+EpWu+x3aR6o4GWt+UI+27zmE7w6t/6d80mXqOtWLdEKQ13vL/g1Jry0ae2icj6GP7tGzA==}
'@fastify/static@10.1.2':
resolution: {integrity: sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==}
'@file-type/xml@0.4.4':
resolution: {integrity: sha512-NhCyXoHlVZ8TqM476hyzwGJ24+D5IPSaZhmrPj7qXnEVb3q6jrFzA3mM9TBpknKSI9EuQeGTKRg2DXGUwvBBoQ==}
@@ -2447,6 +2450,9 @@ packages:
'@misskey-dev/summaly@5.5.1':
resolution: {integrity: sha512-CfpVtmFgltjNS1FAS5+bOluKAF/irZuQnUxAw66ydnp/uesiNNu62ltf9gEgwutT5S+itGtlnP87AOx6DDn/1A==}
'@misskey-dev/tagcanvas-es@0.1.2':
resolution: {integrity: sha512-SGqyu30/CCQyIC95Zcmj3k1vYXwfX66/arsxpl1UUulUqvlRpTpuhB7kEAAps1lqXin0rQzXknb31EeveNKsaw==}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
@@ -6494,8 +6500,8 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
js-yaml@5.2.1:
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==}
js-yaml@5.2.2:
resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==}
hasBin: true
jsbn@0.1.1:
@@ -8607,8 +8613,8 @@ packages:
tar-stream@3.2.0:
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
tar@7.5.20:
resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==}
tar@7.5.21:
resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==}
engines: {node: '>=18'}
taskkill@5.0.0:
@@ -10082,7 +10088,7 @@ snapshots:
http-errors: 2.0.1
mime: 3.0.0
'@fastify/static@10.1.0':
'@fastify/static@10.1.2':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/error': 4.2.0
@@ -10505,6 +10511,8 @@ snapshots:
optionalDependencies:
fastify: 5.8.5
'@misskey-dev/tagcanvas-es@0.1.2': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true
@@ -15034,7 +15042,7 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@5.2.1:
js-yaml@5.2.2:
dependencies:
argparse: 2.0.1
@@ -15920,7 +15928,7 @@ snapshots:
nopt: 10.0.1
proc-log: 7.0.0
semver: 7.8.5
tar: 7.5.20
tar: 7.5.21
tinyglobby: 0.2.17
undici: 6.25.0
which: 7.0.0
@@ -17578,7 +17586,7 @@ snapshots:
- bare-buffer
- react-native-b4a
tar@7.5.20:
tar@7.5.21:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
+6
View File
@@ -58,6 +58,12 @@ minimumReleaseAgeExclude:
- "@opentelemetry/core@2.8.0"
# Renovate security update: typeorm@1.1.0
- typeorm@1.1.0
# Renovate security update: @fastify/static@10.1.2
- "@fastify/static@10.1.2"
# Renovate security update: tar@7.5.21
- tar@7.5.21
# Renovate security update: js-yaml@5.2.2
- js-yaml@5.2.2
overrides:
'@aiscript-dev/aiscript-languageserver': '-'
'bullmq>ioredis': 5.11.1