1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-10 10:24:46 +02:00

Compare commits

..

43 Commits

Author SHA1 Message Date
syuilo
c5dd17e7e2 wip 2026-07-10 17:08:47 +09:00
syuilo
a129b453b0 Update MkImageGallery.item.vue 2026-07-10 16:43:15 +09:00
syuilo
392fb2c40d Update MkImageGallery.item.vue 2026-07-10 16:41:26 +09:00
syuilo
3ecb2b4d13 wip 2026-07-10 15:48:39 +09:00
syuilo
f3dcca2d31 wip 2026-07-10 15:12:00 +09:00
syuilo
327c447b9a Delete animation.ts 2026-07-10 15:03:00 +09:00
syuilo
62afbc32fb Update MkImageGallery.vue 2026-07-10 15:02:19 +09:00
syuilo
5677646bd5 Update MkImageGallery.item.vue 2026-07-10 14:41:54 +09:00
syuilo
53b4d184ac refactor 2026-07-10 14:16:38 +09:00
syuilo
1cabf88445 Update MkImageGallery.item.vue 2026-07-10 13:53:54 +09:00
syuilo
b47303f6d6 wip 2026-07-10 13:46:45 +09:00
syuilo
b48321154f wip 2026-07-10 13:18:54 +09:00
syuilo
f70048bd6c Update MkImageGallery.item.vue 2026-07-10 12:01:13 +09:00
syuilo
4b592f9c94 wip 2026-07-10 09:56:03 +09:00
syuilo
f5f5eab09e fix 2026-07-10 09:34:08 +09:00
syuilo
324aea004d Update MkImageGallery.item.vue 2026-07-10 09:26:14 +09:00
syuilo
dc07f0c6de Update MkMediaList.vue 2026-07-10 09:24:16 +09:00
syuilo
2e54afd3a7 wip 2026-07-10 09:21:42 +09:00
syuilo
e41aedeb06 wip 2026-07-10 09:03:03 +09:00
syuilo
c3f8c0a871 Update MkImageGallery.vue 2026-07-10 08:45:16 +09:00
syuilo
95901e5220 Update MkImageGallery.vue 2026-07-10 08:36:42 +09:00
syuilo
d5609abf25 wip 2026-07-09 22:02:37 +09:00
syuilo
cf3b206ef4 wip 2026-07-09 21:24:33 +09:00
syuilo
951e22242d wip 2026-07-09 21:05:14 +09:00
syuilo
cc356cf542 wip 2026-07-09 20:32:20 +09:00
syuilo
f0b3a7241d wip 2026-07-09 18:48:28 +09:00
syuilo
e7bc3821d1 Update MkImageGallery.item.vue 2026-07-09 18:25:08 +09:00
syuilo
d2647e066e Update MkImageGallery.item.vue 2026-07-09 17:53:09 +09:00
syuilo
b7ca70bd1e wip 2026-07-09 17:42:34 +09:00
syuilo
564ed21b74 Update MkImageGallery.item.vue 2026-07-09 17:31:09 +09:00
syuilo
5c7f547342 wip 2026-07-09 17:03:20 +09:00
syuilo
75e97da41e wip 2026-07-09 16:13:16 +09:00
かっこかり
059fbaa92b fix(frontend/test): follow-up of #17682 of 2 (#17685)
* fix(frontend/test): follow-up of #17682 of 2

* fix
2026-07-08 22:37:19 +09:00
かっこかり
76ab562810 fix: follow-up of #17682 [ci skip] 2026-07-08 21:19:35 +09:00
かっこかり
9472e72678 enhance(frontend/test): frontend e2eをPlaywrightに移行 (#17682)
* enhance(frontend/test): frontend e2eをVitest Browser Modeに移行

* fix

* fix unit tests

* fix

* fix

* update playwright

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* perf

* Revert "perf"

This reverts commit e60e965c3c.

* fix
2026-07-08 20:58:02 +09:00
かっこかり
9440423a73 refactor(frontend): フロントエンドのfluent-emojisをemoji-assetsから取ってくるように (#17662)
refactor(frontend): フロントエンドのfluent-emojis使用箇所を `@misskey-dev/emoji-assets` のものに変更
2026-07-08 20:11:13 +09:00
かっこかり
b2867eb0d7 fix(frontend): ディレクティブのイベントリスナーがマウント解除後も残る問題を修正 (#17683)
* fix(frontend): ツールチップがマウント解除後もイベントリスナーが残る問題を修正

* more fix

* fix

* fix/refactor

* Update Changelog

* update comment

* refactor

* fix
2026-07-08 19:45:43 +09:00
syuilo
5590f8bad9 chore(backend): COOP/COEPヘッダーを送信するテスト用引数 2026-07-08 13:15:03 +09:00
おさむのひと
c15f93e48d fix: Sentry 使用環境下にて、外部送信リクエストへ sentry-trace / baggage ヘッダが既定で付与されないように (#17677)
* fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように

* fix
2026-07-08 09:46:44 +09:00
github-actions[bot]
03d224acd0 Bump version to 2026.7.0-alpha.2 2026-07-06 10:03:45 +00:00
かっこかり
c24ce9b55a deps: update dependencies (#17669)
* deps: update dependencies

* deps: update dependencies (major)

* migrate js-yaml

* migrate js-yaml

* run pnpm dedupe

* fix

* chore: reorganize dependencies

* perf: lazy-load id generators

* Revert "perf: lazy-load id generators"

This reverts commit 041f7c4206.
2026-07-06 18:55:06 +09:00
おさむのひと
7243bd34b9 fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正 (#17671)
* fix: pgをexternalにしてSentryのspanが動くようにする

* fix changelog

* add comment
2026-07-06 18:54:04 +09:00
おさむのひと
d4dd02c744 feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する (#17673)
* feat: Sentryバックエンドの自動計装を個別で無効化出来る仕組みを追加する

* fix comment
2026-07-06 18:52:57 +09:00
188 changed files with 3009 additions and 3338 deletions

View File

@@ -35,7 +35,7 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md``references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Cypress 適用ガイド |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |

View File

@@ -21,7 +21,7 @@ note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック
`/quality-gate [scope]`
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Cypress) は CI 側で実行されるため、本コマンドには含めない。
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない。
## Scope
@@ -120,4 +120,4 @@ Frontend ut: PASS (87/87)
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
- e2e / federation / Cypress は重いため除外し CI 側に委譲。
- e2e / federation / Playwright は重いため除外し CI 側に委譲。

View File

@@ -26,7 +26,7 @@ cp .github/misskey/test.yml .config/test.yml
補足:
- ルートの `pnpm start:test` (Cypress 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- ルートの `pnpm start:test` (Playwright 用にテストサーバーを起動するコマンド) を使う経路では実行時に `ncp` で自動コピーされる ([package.json](../../../../../package.json))。それ以外で backend テストを直接走らせる時は上記の手動コピーが必要
- すでに `.config/test.yml` があれば各テストスクリプトの内部 `compile-config` で十分なので、追加で `pnpm --filter backend compile-config` を叩く必要はない
- `pnpm start:test` は backend e2e テスト (`pnpm --filter backend test:e2e`) の前提ではない (ポート競合の元になるため使わないこと)

View File

@@ -27,7 +27,7 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
- SCSS Modules / `--MI_THEME-*` `--MI-*` CSS 変数 / グローバル utility class (`_button` 等) → [references/knowledge/scss-modules.md](references/knowledge/scss-modules.md)
- `os.alert` / `os.confirm` / `os.popup` 等 UI ヘルパー (ブラウザ標準 `alert()` 直呼びは禁止) → [references/knowledge/os-api.md](references/knowledge/os-api.md)
- `*.stories.impl.ts` 併設規則 + 複数 story / argTypes / layout / action パターン → [references/knowledge/storybook.md](references/knowledge/storybook.md)
- frontend Vitest / Cypress E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
- frontend Vitest / Playwright E2E の書き方と前提 → [references/knowledge/frontend-testing.md](references/knowledge/frontend-testing.md)
## 必ず最後に通る場所

View File

@@ -1,4 +1,4 @@
# Frontend テスト (Vitest / Cypress)
# Frontend テスト (Vitest / Playwright)
Misskey frontend のテスト構成。
@@ -13,11 +13,11 @@ pnpm --filter frontend test-and-coverage # カバレッジ付き
- 主な配置: `packages/frontend/test/*.test.ts` (例: `i18n.test.ts`, `theme.test.ts`, `is-birthday.test.ts`)
- ビルドツール周りなど対象コードと隣接させた方が分かりやすいテストは、コードと同じディレクトリに `*.test.ts` として置く (例: [packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts](../../../../../packages/frontend/lib/rollup-plugin-unwind-css-module-class-name.test.ts))
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Cypress でカバー)
- 共有コンポーネント (`MkX.vue`) のユニットテストは現状少なく、`*.spec.ts` / `__tests__/` 形式は採用していない (Storybook + Playwright でカバー)
## Cypress E2E
## Playwright E2E
Cypress**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
Playwright**起動済みのテストサーバー** に対して走るため、unit より前提が多い。[.github/workflows/test-frontend.yml](../../../../../.github/workflows/test-frontend.yml) の `e2e` ジョブと同じ手順をローカルで踏む:
```bash
# 1. テスト用 DB / Redis を起動 (テスト用ポート。開発用の compose.local-db.yml ではない)
@@ -29,15 +29,14 @@ cp .github/misskey/test.yml .config/test.yml
# 3. 全体ビルド
pnpm build
# 4. テストサーバー起動 + Cypress 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Cypress run
pnpm cy:open # 対話的に開く (サーバーは別途 pnpm start:test で起動しておく)
# 4. テストサーバー起動 + Playwright 実行 (いずれもルートから)
pnpm e2e # 内部で pnpm start:test を起動し http://localhost:61812 を待って Playwright を実行
```
- 設定: ルート [cypress.config.ts](../../../../../cypress.config.ts)
- テスト本体は [cypress/](../../../../../cypress/) 配下
- 設定: ルート [packages/frontend/playwright.config.ts](../../../../../packages/frontend/playwright.config.ts) で、`packages/frontend/test/e2e/` 配下の `*.spec.ts` を対象にする
- テスト本体は [packages/frontend/test/e2e/](../../../../../packages/frontend/test/e2e/) 配下に配置する。
新規 frontend 機能の E2E は Cypress に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
新規 frontend 機能の E2E は Playwright に書くのが基本。ただし対象は主要 UI フロー (login / post / drive etc) に限定し、細かい単位テストは Vitest または Storybook で代替する慣習。
## Storybook (視覚確認 + Chromatic 視覚回帰)
@@ -55,6 +54,6 @@ pnpm --filter frontend build-storybook # 静的ビルド
frontend のテスト種別で DB / Redis の要否が違う:
- **Vitest (unit)** — DB 不要。ロジック / コンポーネント単体のテストで backend に繋がない (CI の `vitest` ジョブにも `services:` は無い)
- **Cypress (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Cypress E2E の手順を参照)
- **Playwright (E2E)** — テストサーバー (`pnpm start:test`) 経由で backend に繋ぐため DB / Redis が必要。**テスト用ポートの [packages/backend/test/compose.yml](../../../../../packages/backend/test/compose.yml)** を使う (上記 Playwright E2E の手順を参照)
開発用の `compose.local-db.yml` (db `5432` / redis `6379`) は **テストには使わない**。テスト用の `packages/backend/test/compose.yml` (`54312` / `56312`) とはポートが異なり、混同すると接続できない。

View File

@@ -303,8 +303,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:

View File

@@ -12,4 +12,4 @@ pnpm install --frozen-lockfile
cp .devcontainer/devcontainer.yml .config/default.yml
pnpm build
pnpm migrate
pnpm exec cypress install
pnpm --filter frontend exec playwright install --with-deps chromium

2
.github/labeler.yml vendored
View File

@@ -16,7 +16,7 @@
'packages/frontend:test':
- any:
- changed-files:
- any-glob-to-any-file: ['cypress/**/*']
- any-glob-to-any-file: ['packages/frontend/test/**/*']
'packages/sw':
- any:

View File

@@ -44,7 +44,6 @@ jobs:
}
directories=(
"cypress/e2e"
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"

View File

@@ -57,11 +57,6 @@ jobs:
name: E2E tests (frontend)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chrome]
services:
postgres:
image: postgres:18
@@ -79,12 +74,6 @@ jobs:
- uses: actions/checkout@v6.0.3
with:
submodules: true
# https://github.com/cypress-io/cypress-docker-images/issues/150
#- name: Install mplayer for FireFox
# run: sudo apt install mplayer -y
# if: ${{ matrix.browser == 'firefox' }}
#- uses: browser-actions/setup-firefox@latest
# if: ${{ matrix.browser == 'firefox' }}
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
- name: Use Node.js
@@ -97,29 +86,14 @@ jobs:
run: cp .github/misskey/test.yml .config
- name: Build
run: pnpm build
# https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091
- name: ALSA Env
run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc
# XXX: This tries reinstalling Cypress if the binary is not cached
# Remove this when the cache issue is fixed
- name: Cypress install
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@v7.4.0
- name: Playwright install
working-directory: packages/frontend
run: pnpm exec playwright install --with-deps chromium
- name: Test
run: pnpm start-server-and-test start:test http://localhost:61812 "pnpm --filter frontend test:e2e"
timeout-minutes: 15
with:
install: false
start: pnpm start:test
wait-on: 'http://localhost:61812'
headed: true
browser: ${{ matrix.browser }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: ${{ matrix.browser }}-cypress-screenshots
path: cypress/screenshots
- uses: actions/upload-artifact@v7
if: always()
with:
name: ${{ matrix.browser }}-cypress-videos
path: cypress/videos
name: playwright-e2e-artifacts
path: packages/frontend/test/e2e/artifacts

7
.gitignore vendored
View File

@@ -23,9 +23,8 @@ packages/sw/.yarn/cache
# pnpm
.pnpm-store
# Cypress
cypress/screenshots
cypress/videos
# Playwright
packages/frontend/test/e2e/artifacts
# Coverage
coverage
@@ -35,7 +34,7 @@ coverage
!/.config/example.yml
!/.config/docker_example.yml
!/.config/docker_example.env
!/.config/cypress-devcontainer.yml
!/.config/playwright-devcontainer.yml
docker-compose.yml
./compose.yml
.devcontainer/compose.yml

View File

@@ -14,10 +14,6 @@
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
### General
- Feat: 公開ロールとロールバッジをユーザー側で個別に非表示にできるように
- Feat: 公開ロールとロールバッジを、上記の設定にかかわらず強制的に表示させることができるように
- 既存のロールについてはすべて強制表示が有効となります。必要に応じて設定を変更してください。
- 今後新規作成するロールのデフォルト値は強制表示なし(ユーザーが非表示にできる)となります。
- Feat: コントロールパネルから二要素認証を解除できるように
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
(Based on https://github.com/MisskeyIO/misskey/pull/214)
@@ -28,13 +24,17 @@
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
## 2026.6.0

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:61812',
},
})

View File

@@ -1,265 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Before setup instance', () => {
beforeEach(() => {
cy.resetState();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('setup instance', () => {
cy.visitHome();
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
cy.get('[data-cy-admin-username] input').type('admin');
cy.get('[data-cy-admin-password] input').type('admin1234');
cy.get('[data-cy-admin-ok]').click();
// なぜか動かない
//cy.wait('@signup').should('have.property', 'response.statusCode');
cy.wait('@signup');
cy.intercept('POST', '/api/admin/update-meta').as('update-meta');
cy.get('[data-cy-next]').click();
cy.get('[data-cy-server-name] input').type('Testskey');
cy.get('[data-cy-server-setup-wizard-apply]').click();
cy.wait('@update-meta');
});
});
describe('After setup instance', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signup', () => {
cy.visitHome();
cy.intercept('POST', '/api/signup').as('signup');
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code');
cy.get('[data-cy-signup-submit]').should('not.be.disabled');
cy.get('[data-cy-signup-submit]').click();
cy.wait('@signup');
});
it('signup with duplicated username', () => {
cy.registerUser('alice', 'alice1234');
cy.visitHome();
// ユーザー名が重複している場合の挙動確認
cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-password-retype] input').type('alice1234');
cy.get('[data-cy-signup-submit]').should('be.disabled');
});
});
describe('After user signup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
cy.visitHome();
});
it('signin', () => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-username] input').type('alice{enter}');
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin');
});
it('suspend', function() {
cy.request('POST', '/api/admin/suspend-user', {
i: this.admin.token,
userId: this.alice.id,
});
cy.visitHome();
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type('alice{enter}');
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
});
});
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('successfully loads', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).should('be.visible');
});
it('account setup wizard', () => {
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup-continue]', { timeout: 30000 }).click();
cy.get('[data-cy-user-setup-user-name] input').type('ありす');
cy.get('[data-cy-user-setup-user-description] textarea').type('ほげ');
// TODO: アイコン設定テスト
cy.get('[data-cy-user-setup-continue]').click();
// プライバシー設定
cy.get('[data-cy-user-setup-continue]').click();
// フォローはスキップ
cy.get('[data-cy-user-setup-continue]').click();
// プッシュ通知設定はスキップ
cy.get('[data-cy-user-setup-continue]').click();
cy.get('[data-cy-user-setup-continue]').click();
});
});
describe('After user setup', () => {
beforeEach(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('note', () => {
cy.get('[data-cy-open-post-form]').should('be.visible');
cy.get('[data-cy-open-post-form]').click();
cy.get('[data-cy-post-form-text]').type('Hello, Misskey!');
cy.get('[data-cy-open-post-form-submit]').click();
cy.contains('Hello, Misskey!', { timeout: 15000 });
});
it('open note form with hotkey', () => {
// Wait until the page loads
cy.get('[data-cy-open-post-form]').should('be.visible');
// Use trigger() to give different `code` to test if hotkeys also work on non-QWERTY keyboards.
cy.document().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "n", code: "KeyL" });
// See if the form is opened
cy.get('[data-cy-post-form-text]').should('be.visible');
// Close it
cy.focused().trigger("keydown", { eventConstructor: 'KeyboardEvent', key: "Escape", code: "Escape" });
// See if the form is closed
cy.get('[data-cy-post-form-text]').should('not.be.visible');
});
});
// TODO: 投稿フォームの公開範囲指定のテスト
// TODO: 投稿フォームのファイル添付のテスト
// TODO: 投稿フォームのハッシュタグ保持フィールドのテスト

View File

@@ -1,35 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Router transition', () => {
describe('Redirect', () => {
// サーバの初期化。ルートのテストに関しては各describeごとに1度だけ実行で十分だと思う使いまわした方が早い
before(() => {
cy.resetState();
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]', { timeout: 30000 }).click();
cy.wait(500);
cy.get('[data-cy-modal-dialog-ok]').click();
});
it('redirect to user profile', () => {
// テストのためだけに用意されたリダイレクト用ルートに飛ぶ
cy.visit('/redirect-test');
// プロフィールページのURLであることを確認する
cy.url().should('include', '/@alice')
});
});
});

View File

@@ -1,76 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* flaky
describe('After user signed in', () => {
beforeEach(() => {
cy.resetState();
cy.viewport('macbook-16');
// インスタンス初期セットアップ
cy.registerUser('admin', 'pass', true);
// ユーザー作成
cy.registerUser('alice', 'alice1234');
cy.login('alice', 'alice1234');
// アカウント初期設定ウィザード
cy.get('[data-cy-user-setup] [data-cy-modal-window-close]').click();
cy.get('[data-cy-modal-dialog-ok]').click();
});
afterEach(() => {
// テスト終了直前にページ遷移するようなテストケース(例えばアカウント作成)だと、たぶんCypressのバグでブラウザの内容が次のテストケースに引き継がれてしまう(例えばアカウントが作成し終わった段階からテストが始まる)。
// waitを入れることでそれを防止できる
cy.wait(1000);
});
it('widget edit toggle is visible', () => {
cy.get('[data-cy-widget-edit]').should('be.visible');
});
it('widget select should be visible in edit mode', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select]').should('be.visible');
});
it('first widget should be removed', () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click();
cy.get('[data-cy-customize-container]').should('have.length', 2);
});
function buildWidgetTest(widgetName) {
it(`${widgetName} widget should get added`, () => {
cy.get('[data-cy-widget-edit]').click();
cy.get('[data-cy-widget-select] select').select(widgetName, { force: true });
cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true });
cy.get('[data-cy-widget-add]').click({ force: true });
cy.get(`[data-cy-mkw-${widgetName}]`).should('exist');
});
}
buildWidgetTest('memo');
buildWidgetTest('notifications');
buildWidgetTest('timeline');
buildWidgetTest('calendar');
buildWidgetTest('rss');
buildWidgetTest('trends');
buildWidgetTest('clock');
buildWidgetTest('activity');
buildWidgetTest('photos');
buildWidgetTest('digitalClock');
buildWidgetTest('federation');
buildWidgetTest('postForm');
buildWidgetTest('slideshow');
buildWidgetTest('serverMetric');
buildWidgetTest('onlineUsers');
buildWidgetTest('jobQueue');
buildWidgetTest('button');
buildWidgetTest('aiscript');
buildWidgetTest('aichan');
});
*/

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,67 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
Cypress.Commands.add('visitHome', () => {
cy.visit('/');
cy.get('button', { timeout: 30000 }).should('be.visible');
})
Cypress.Commands.add('resetState', () => {
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
/*
cy.window().then(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
*/
cy.request('POST', '/api/reset-db', {}).as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);
});
Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup';
cy.request('POST', route, {
username: username,
password: password,
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
}).its('body').as(username);
});
Cypress.Commands.add('login', (username, password) => {
cy.visitHome();
cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn');
});

View File

@@ -1,34 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err, runnable) => {
if ([
'The source image cannot be decoded',
// Chrome
'ResizeObserver loop limit exceeded',
// Firefox
'ResizeObserver loop completed with undelivered notifications',
].some(msg => err.message.includes(msg))) {
return false;
}
});

View File

@@ -1,19 +0,0 @@
declare global {
namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>;
registerUser(
username: string,
password: string,
isAdmin?: boolean
): Chainable<void>;
resetState(): Chainable<void>;
visitHome(): Chainable<void>;
}
}
}
export {}

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"lib": ["dom"],
"target": "esnext",
"types": ["cypress", "node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -2083,8 +2083,6 @@ _role:
isConditionalRole: "これはコンディショナルロールです。"
isPublic: "公開ロール"
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
options: "オプション"
policies: "ポリシー"
baseRole: "ベースロール"
@@ -3247,12 +3245,6 @@ _gridComponent:
patternNotMatch: "この値は{pattern}のパターンに一致しません"
notUnique: "この値は一意である必要があります"
_roleDisplay:
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
displayToggle: "ロール/ロールバッジを表示する"
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"
_roleSelectDialog:
notSelected: "選択されていません"

View File

@@ -1,12 +1,12 @@
{
"name": "misskey",
"version": "2026.7.0-alpha.1",
"version": "2026.7.0-alpha.2",
"codename": "nasubi",
"repository": {
"type": "git",
"url": "https://github.com/misskey-dev/misskey.git"
},
"packageManager": "pnpm@11.8.0",
"packageManager": "pnpm@11.9.0",
"workspaces": [
"packages/misskey-js",
"packages/i18n",
@@ -41,10 +41,8 @@
"dev": "node scripts/dev.mjs",
"check-dts": "node scripts/check-dts.mjs",
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
"cy:run": "pnpm cypress run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
"backend-unit-test": "cd packages/backend && pnpm test",
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
"test": "pnpm -r test",
@@ -57,23 +55,21 @@
"esbuild": "0.28.1",
"execa": "9.6.1",
"ignore-walk": "9.0.0",
"js-yaml": "4.2.0",
"tar": "7.5.16"
"js-yaml": "5.2.0",
"tar": "7.5.19"
},
"devDependencies": {
"@eslint/js": "9.39.4",
"@misskey-dev/eslint-plugin": "2.1.0",
"@types/js-yaml": "4.0.9",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.17.0",
"eslint": "9.39.4",
"globals": "17.6.0",
"globals": "17.7.0",
"ncp": "2.0.0",
"pnpm": "11.8.0",
"pnpm": "11.9.0",
"start-server-and-test": "3.0.11",
"typescript": "5.9.3"
}

View File

@@ -1,21 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RoleDisplayVisibility1783059479536 {
name = 'RoleDisplayVisibility1783059479536';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`);
// 既存のロールについてはすべて強制表示とする新規作成分についてはfalse
await queryRunner.query(`UPDATE "role" SET "isPublicDisplayRequired" = true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`);
}
}

View File

@@ -51,8 +51,8 @@
"utf-8-validate": "6.0.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1073.0",
"@aws-sdk/lib-storage": "3.1073.0",
"@aws-sdk/client-s3": "3.1075.0",
"@aws-sdk/lib-storage": "3.1075.0",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/http-proxy": "11.5.0",
@@ -63,23 +63,22 @@
"@misskey-dev/emoji-data": "17.0.3",
"@misskey-dev/sharp-read-bmp": "1.3.1",
"@misskey-dev/summaly": "5.5.1",
"@napi-rs/canvas": "1.0.0",
"@napi-rs/canvas": "1.0.1",
"@nestjs/common": "11.1.27",
"@nestjs/core": "11.1.27",
"@nestjs/testing": "11.1.27",
"@oxc-project/runtime": "0.137.0",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "10.59.0",
"@sentry/profiling-node": "10.59.0",
"@simplewebauthn/server": "13.3.1",
"@sinonjs/fake-timers": "15.4.0",
"@smithy/node-http-handler": "4.8.1",
"@sentry/node": "10.62.0",
"@sentry/profiling-node": "10.62.0",
"@simplewebauthn/server": "13.3.2",
"@smithy/node-http-handler": "4.8.2",
"accepts": "1.3.8",
"ajv": "8.20.0",
"archiver": "8.0.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"bullmq": "5.79.0",
"bullmq": "5.79.2",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -88,12 +87,12 @@
"content-disposition": "2.0.1",
"date-fns": "4.4.0",
"deep-email-validator": "0.1.27",
"fastify": "5.8.5",
"fastify": "5.9.0",
"fastify-raw-body": "5.0.0",
"feed": "5.2.1",
"file-type": "22.0.1",
"fluent-ffmpeg": "2.1.3",
"got": "15.0.5",
"got": "15.0.7",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
@@ -110,10 +109,10 @@
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.14",
"nanoid": "5.1.16",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"node-html-parser": "7.1.0",
"node-html-parser": "8.0.3",
"nodemailer": "9.0.1",
"os-utils": "0.0.14",
"otpauth": "9.5.1",
@@ -135,7 +134,7 @@
"slacc": "0.1.5",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.31.7",
"systeminformation": "5.31.11",
"tinycolor2": "1.6.0",
"tmp": "0.2.7",
"tsc-alias": "1.8.17",
@@ -150,16 +149,16 @@
"@kitajs/ts-html-plugin": "4.1.4",
"@nestjs/platform-express": "11.1.27",
"@rollup/plugin-esm-shim": "0.1.8",
"@sentry/vue": "10.59.0",
"@sentry/vue": "10.62.0",
"@sinonjs/fake-timers": "15.4.0",
"@types/accepts": "1.3.7",
"@types/archiver": "8.0.0",
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/js-yaml": "4.0.9",
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "26.0.0",
"@types/node": "26.0.1",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/qrcode": "1.5.6",
@@ -170,14 +169,13 @@
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "7.2.0",
"@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
"aws-sdk-client-mock": "4.1.0",
"cbor2": "2.3.0",
@@ -185,11 +183,10 @@
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.3",
"js-yaml": "4.2.0",
"js-yaml": "5.2.0",
"pid-port": "2.1.1",
"rolldown": "1.1.2",
"rolldown": "1.1.3",
"simple-oauth2": "5.1.0",
"supertest": "7.2.2",
"vite": "8.1.0",
"vitest": "4.1.9",
"vitest-mock-extended": "4.0.0"

View File

@@ -75,6 +75,8 @@ export default defineConfig((args) => {
're2',
'ipaddr.js',
'file-type',
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
'pg',
];
const define: Record<string, string> = {

View File

@@ -11,7 +11,7 @@
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { load as loadYaml } from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -34,7 +34,7 @@ function yamlToJson(ymlPath) {
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
const jsonContent = loadYaml(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}

View File

@@ -20,6 +20,12 @@ type RedisOptionsSource = Partial<RedisOptions> & {
prefix?: string;
};
type SentryBackendConfig = {
options: Partial<Sentry.NodeOptions>;
enableNodeProfiling: boolean;
disabledIntegrations?: string[];
};
/**
* 設定ファイルの型
*/
@@ -64,7 +70,7 @@ type Source = {
index: string;
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
sentryForBackend?: SentryBackendConfig;
sentryForFrontend?: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;
@@ -201,7 +207,7 @@ export type Config = {
redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
redisForReactions: RedisOptions & RedisOptionsSource;
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
sentryForBackend: SentryBackendConfig | undefined;
sentryForFrontend: {
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
vueIntegration?: SentryVue.VueIntegrationOptions | null;

View File

@@ -3,11 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { URLSearchParams } from 'node:url';
import * as nodemailer from 'nodemailer';
import juice from 'juice';
import { Inject, Injectable } from '@nestjs/common';
import { validate as validateEmail } from 'deep-email-validator';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -197,6 +195,7 @@ export class EmailService {
} else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) {
validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey);
} else {
const { validate: validateEmail } = await import('deep-email-validator');
validated = await validateEmail({
email: emailAddress,
validateRegex: true,

View File

@@ -676,7 +676,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
target: values.target,
condFormula: values.condFormula,
isPublic: values.isPublic,
isPublicDisplayRequired: values.isPublicDisplayRequired ?? false,
isAdministrator: values.isAdministrator,
isModerator: values.isModerator,
isExplorable: values.isExplorable,

View File

@@ -55,7 +55,6 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
makeNotesHiddenBefore: null,
chatScope: 'mutual',
emojis: [],
hiddenRoleIds: [],
score: 0,
host: null,
inbox: null,

View File

@@ -64,7 +64,6 @@ export class RoleEntityService {
target: role.target,
condFormula: role.condFormula,
isPublic: role.isPublic,
isPublicDisplayRequired: role.isPublicDisplayRequired,
isAdministrator: role.isAdministrator,
isModerator: role.isModerator,
isExplorable: role.isExplorable,
@@ -84,32 +83,5 @@ export class RoleEntityService {
) {
return Promise.all(roles.map(x => this.pack(x, me)));
}
@bindThis
public async packLite(
src: MiRole['id'] | MiRole,
): Promise<Packed<'RoleLite'>> {
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
return {
id: role.id,
name: role.name,
color: role.color,
iconUrl: role.iconUrl,
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
asBadge: role.asBadge,
isPublicDisplayRequired: role.isPublicDisplayRequired,
isExplorable: role.isExplorable,
displayOrder: role.displayOrder,
};
}
@bindThis
public packLiteMany(
roles: (MiRole | MiRole['id'])[],
) {
return Promise.all(roles.map(x => this.packLite(x)));
}
}

View File

@@ -14,7 +14,6 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { MiRole } from '@/models/Role.js';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import {
birthdaySchema,
@@ -52,7 +51,6 @@ import { ChatService } from '@/core/ChatService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
import type { RoleEntityService } from './RoleEntityService.js';
import { toArray } from '@/misc/prelude/array.js';
const Ajv = _Ajv.default;
@@ -90,7 +88,6 @@ export class UserEntityService implements OnModuleInit {
private apPersonService: ApPersonService;
private noteEntityService: NoteEntityService;
private pageEntityService: PageEntityService;
private roleEntityService: RoleEntityService;
private customEmojiService: CustomEmojiService;
private announcementService: AnnouncementService;
private roleService: RoleService;
@@ -147,7 +144,6 @@ export class UserEntityService implements OnModuleInit {
this.apPersonService = this.moduleRef.get('ApPersonService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.pageEntityService = this.moduleRef.get('PageEntityService');
this.roleEntityService = this.moduleRef.get('RoleEntityService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.announcementService = this.moduleRef.get('AnnouncementService');
this.roleService = this.moduleRef.get('RoleService');
@@ -406,30 +402,6 @@ export class UserEntityService implements OnModuleInit {
return `${this.config.url}/users/${userId}`;
}
@bindThis
private prepareRoles<T extends Pick<MiRole, 'id' | 'isPublic' | 'displayOrder' | 'isPublicDisplayRequired'>>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] {
const hiddenRoleIds = new Set(user.hiddenRoleIds);
return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder);
}
@bindThis
private sanitizeHiddenRoleIds(roles: Pick<MiRole, 'id' | 'isPublic' | 'isPublicDisplayRequired'>[], user: MiUser): MiRole['id'][] {
const hideableRoleIds = new Set(roles
.filter(role => role.isPublic && !role.isPublicDisplayRequired)
.map(role => role.id));
const sanitizedRoleIds: MiRole['id'][] = [];
const seenRoleIds = new Set<MiRole['id']>();
for (const roleId of user.hiddenRoleIds) {
if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue;
sanitizedRoleIds.push(roleId);
seenRoleIds.add(roleId);
}
return sanitizedRoleIds;
}
public async pack<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>(
src: MiUser['id'] | MiUser,
me?: { id: MiUser['id']; } | null | undefined,
@@ -453,7 +425,6 @@ export class UserEntityService implements OnModuleInit {
const meId = me ? me.id : null;
const isMe = meId === user.id;
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null;
const profile = isDetailed
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
@@ -543,9 +514,10 @@ export class UserEntityService implements OnModuleInit {
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user),
// パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator)
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
.filter((r) => r.isPublic || iAmModerator)
.sort((a, b) => b.displayOrder - a.displayOrder)
.map((r) => ({
id: r.id,
name: r.name,
iconUrl: r.iconUrl,
displayOrder: r.displayOrder,
@@ -588,7 +560,16 @@ export class UserEntityService implements OnModuleInit {
followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope,
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))),
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
color: role.color,
iconUrl: role.iconUrl,
description: role.description,
isModerator: role.isModerator,
isAdministrator: role.isAdministrator,
displayOrder: role.displayOrder,
}))),
memo: memo,
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}),
@@ -617,7 +598,6 @@ export class UserEntityService implements OnModuleInit {
preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)),
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: false, // 後方互換性のため

View File

@@ -6,6 +6,63 @@
import type { Config } from '@/config.js';
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
type SentryIntegrationsOption = NonNullable<import('@sentry/node').NodeOptions['integrations']>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SentryIntegrationFactory = Extract<SentryIntegrationsOption, (integrations: any[]) => any[]>;
type SentryIntegration = Parameters<SentryIntegrationFactory>[0][number];
type SentryNodeOptions = import('@sentry/node').NodeOptions;
type BuildSentryIntegrationsOptions = {
disabledIntegrations?: string[];
enableNodeProfiling: boolean;
nodeProfilingIntegration?: () => SentryIntegration;
warn?: (message: string) => void;
};
export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions): SentryIntegrationFactory {
return (defaults) => {
const disabledIntegrations = new Set(options.disabledIntegrations ?? []);
const defaultIntegrationNames = new Set(defaults.map((integration) => integration.name));
const unknownIntegrations = [...disabledIntegrations].filter((name) => !defaultIntegrationNames.has(name));
if (unknownIntegrations.length > 0) {
(options.warn ?? console.warn)(`Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: ${unknownIntegrations.join(', ')}`);
}
return [
...defaults.filter((integration) => !disabledIntegrations.has(integration.name)),
...(options.enableNodeProfiling && options.nodeProfilingIntegration != null ? [options.nodeProfilingIntegration()] : []),
];
};
}
export function buildSentryNodeOptions(
config: NonNullable<Config['sentryForBackend']>,
nodeProfilingIntegration?: () => SentryIntegration,
): SentryNodeOptions {
return {
// Do not send Sentry trace headers to remote ActivityPub/Webhook/etc. hosts by default.
// Admins can opt in for trusted internal services via sentryForBackend.options.
tracePropagationTargets: [],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.options,
integrations: buildSentryIntegrations({
disabledIntegrations: config.disabledIntegrations,
enableNodeProfiling: config.enableNodeProfiling,
nodeProfilingIntegration,
}),
};
}
export class SentryTelemetryAdapter implements TelemetryAdapter {
private constructor(
private readonly Sentry: typeof import('@sentry/node'),
@@ -16,21 +73,7 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.options,
});
Sentry.init(buildSentryNodeOptions(config, nodeProfilingIntegration));
return new SentryTelemetryAdapter(Sentry);
}

View File

@@ -11,6 +11,7 @@ const envOption = {
verbose: false,
withLogTime: false,
quiet: false,
enableCrossOriginIsolation: false,
};
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {

View File

@@ -227,11 +227,6 @@ export class MiRole {
})
public isPublic: boolean;
@Column('boolean', {
default: false,
})
public isPublicDisplayRequired: boolean;
// trueの場合ユーザー名の横にバッジとして表示
@Column('boolean', {
default: false,

View File

@@ -6,7 +6,6 @@
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { id } from './util/id.js';
import { MiDriveFile } from './DriveFile.js';
import type { MiRole } from './Role.js';
@Entity('user')
@Index(['usernameLower', 'host'], { unique: true })
@@ -230,11 +229,6 @@ export class MiUser {
})
public emojis: string[];
@Column('varchar', {
length: 32, array: true, default: '{}',
})
public hiddenRoleIds: MiRole['id'][];
// チャットを許可する相手
// everyone: 誰からでも
// followers: フォロワーのみ

View File

@@ -369,21 +369,6 @@ export const packedRoleLiteSchema = {
optional: false, nullable: false,
example: false,
},
asBadge: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isPublicDisplayRequired: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
displayOrder: {
type: 'integer',
optional: false, nullable: false,
@@ -427,11 +412,6 @@ export const packedRoleSchema = {
optional: false, nullable: false,
example: false,
},
isPublicDisplayRequired: {
type: 'boolean',
optional: false, nullable: false,
example: false,
},
isExplorable: {
type: 'boolean',
optional: false, nullable: false,

View File

@@ -176,11 +176,6 @@ export const packedUserLiteSchema = {
type: 'object',
nullable: false, optional: false,
properties: {
id: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
name: {
type: 'string',
nullable: false, optional: false,
@@ -516,15 +511,6 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
hiddenRoleIds: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
format: 'id',
},
},
twoFactorBackupCodesStock: {
type: 'string',
enum: ['full', 'partial', 'none'],

View File

@@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { envOption } from '@/env.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@@ -89,6 +90,15 @@ export class ServerService implements OnApplicationShutdown {
});
}
// for test
if (envOption.enableCrossOriginIsolation) {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Cross-Origin-Opener-Policy', 'same-origin');
reply.header('Cross-Origin-Embedder-Policy', 'credentialless');
done();
});
}
// Register raw-body parser for ActivityPub HTTP signature validation.
await fastify.register(fastifyRawBody, {
global: false,

View File

@@ -32,7 +32,6 @@ export const paramDef = {
target: { type: 'string', enum: ['manual', 'conditional'] },
condFormula: { type: 'object' },
isPublic: { type: 'boolean' },
isPublicDisplayRequired: { type: 'boolean', default: false },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility

View File

@@ -37,7 +37,6 @@ export const paramDef = {
target: { type: 'string', enum: ['manual', 'conditional'] },
condFormula: { type: 'object' },
isPublic: { type: 'boolean' },
isPublicDisplayRequired: { type: 'boolean' },
isModerator: { type: 'boolean' },
isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean' },
@@ -76,7 +75,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
target: ps.target,
condFormula: ps.condFormula,
isPublic: ps.isPublic,
isPublicDisplayRequired: ps.isPublicDisplayRequired,
isModerator: ps.isModerator,
isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable,

View File

@@ -191,12 +191,6 @@ export const paramDef = {
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
hiddenRoleIds: {
type: 'array',
maxItems: 256,
uniqueItems: true,
items: { type: 'string', format: 'misskey:id' },
},
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: muteWords,
hardMutedWords: muteWords,
@@ -299,20 +293,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
if (ps.hiddenRoleIds !== undefined) {
const roles = await this.roleService.getUserRoles(user.id);
const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id));
const hiddenRoleIds: string[] = [];
const seenRoleIds = new Set<string>();
for (const roleId of ps.hiddenRoleIds) {
if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue;
seenRoleIds.add(roleId);
hiddenRoleIds.push(roleId);
}
updates.hiddenRoleIds = hiddenRoleIds;
}
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
const count = (arr: (string[] | string)[]) => {

View File

@@ -128,7 +128,6 @@ describe('ユーザー', () => {
preventAiLearning: user.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
hiddenRoleIds: user.hiddenRoleIds ?? [],
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
@@ -661,16 +660,12 @@ describe('ユーザー', () => {
description: rolePublic.description,
isModerator: rolePublic.isModerator,
isAdministrator: rolePublic.isAdministrator,
asBadge: rolePublic.asBadge,
isPublicDisplayRequired: rolePublic.isPublicDisplayRequired,
isExplorable: rolePublic.isExplorable,
displayOrder: rolePublic.displayOrder,
}]);
});
test('を取得することができ、バッヂロールがセットされていること', async () => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
assert.deepStrictEqual(response.badgeRoles, [{
id: roleBadge.id,
name: roleBadge.name,
iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder,
@@ -683,42 +678,9 @@ describe('ユーザー', () => {
description: roleBadge.description,
isModerator: roleBadge.isModerator,
isAdministrator: roleBadge.isAdministrator,
asBadge: roleBadge.asBadge,
isPublicDisplayRequired: roleBadge.isPublicDisplayRequired,
isExplorable: roleBadge.isExplorable,
displayOrder: roleBadge.displayOrder,
}]);
});
test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => {
const user = await signup({ username: 'userHiddenRoles' });
const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' });
const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' });
const privateRole = await role(root, { isPublic: false, name: 'Private Role' });
const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' });
const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' });
await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root);
await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root);
const response = await successfulApiCall({
endpoint: 'i/update',
parameters: {
hiddenRoleIds: [
secondVisibleRole.id,
'unknownroleid',
unassignedRole.id,
privateRole.id,
forcedRole.id,
visibleRole.id,
],
},
user,
});
assert.deepStrictEqual(response.hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]);
});
test('をID指定のリスト形式で取得することができる', async () => {
const parameters = { userIds: [] };
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });

View File

@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test, vi } from 'vitest';
import { buildSentryIntegrations } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
function testIntegration(name: string): TestIntegration {
return { name };
}
describe('SentryTelemetryAdapter', () => {
test('removes disabled integrations from Sentry defaults', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Postgres'],
enableNodeProfiling: false,
});
const result = integrations([
testIntegration('Http'),
testIntegration('Postgres'),
testIntegration('Redis'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']);
});
test('keeps profiling integration when enabled', () => {
const integrations = buildSentryIntegrations({
disabledIntegrations: [],
enableNodeProfiling: true,
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
});
test('warns about unknown disabled integration names without removing defaults', () => {
const warn = vi.fn();
const integrations = buildSentryIntegrations({
disabledIntegrations: ['Unknown'],
enableNodeProfiling: false,
warn,
});
const result = integrations([
testIntegration('Http'),
]);
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
});
});

View File

@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { describe, expect, test } from 'vitest';
import { buildSentryNodeOptions } from '../../../../../src/core/telemetry/adapters/SentryTelemetryAdapter.js';
describe('buildSentryNodeOptions', () => {
test('disables outbound trace propagation by default', () => {
const options = buildSentryNodeOptions({
enableNodeProfiling: false,
options: {},
});
expect(options.tracePropagationTargets).toEqual([]);
});
test('allows explicit tracePropagationTargets to override the default', () => {
const options = buildSentryNodeOptions({
enableNodeProfiling: false,
options: {
tracePropagationTargets: ['^https://internal\\.example/'],
},
});
expect(options.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
});
});

View File

@@ -14,10 +14,7 @@ import { genAidx } from '@/misc/id/aidx.js';
import {
BlockingsRepository,
FollowingsRepository, FollowRequestsRepository,
MiRole,
MiUserProfile, MutingsRepository, RenoteMutingsRepository,
RoleAssignmentsRepository,
RolesRepository,
UserMemoRepository,
UserProfilesRepository,
UsersRepository,
@@ -30,7 +27,6 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { RoleService } from '@/core/RoleService.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -71,13 +67,6 @@ describe('UserEntityService', () => {
let blockingRepository: BlockingsRepository;
let mutingRepository: MutingsRepository;
let renoteMutingsRepository: RenoteMutingsRepository;
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
let roleService: RoleService;
type RoleServiceCacheController = {
rolesCache: { delete(): void };
roleAssignmentByUserIdCache: { delete(userId: MiUser['id']): void };
};
async function createUser(userData: Partial<MiUser> = {}, profileData: Partial<MiUserProfile> = {}) {
const un = secureRndstr(16);
@@ -98,32 +87,6 @@ describe('UserEntityService', () => {
return user;
}
async function createRole(roleData: Partial<MiRole> = {}) {
const role = await rolesRepository
.insert({
id: genAidx(Date.now()),
updatedAt: new Date(),
lastUsedAt: new Date(),
name: '',
description: '',
...roleData,
})
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
(roleService as unknown as RoleServiceCacheController).rolesCache.delete();
return role;
}
async function assignRole(user: MiUser, role: MiRole) {
await roleAssignmentsRepository.insert({
id: genAidx(Date.now()),
userId: user.id,
roleId: role.id,
});
(roleService as unknown as RoleServiceCacheController).roleAssignmentByUserIdCache.delete(user.id);
}
async function memo(writer: MiUser, target: MiUser, memo: string) {
await userMemosRepository.insert({
id: genAidx(Date.now()),
@@ -183,7 +146,6 @@ describe('UserEntityService', () => {
ApPersonService,
NoteEntityService,
PageEntityService,
RoleEntityService,
CustomEmojiService,
AnnouncementService,
RoleService,
@@ -234,9 +196,6 @@ describe('UserEntityService', () => {
blockingRepository = app.get<BlockingsRepository>(DI.blockingsRepository);
mutingRepository = app.get<MutingsRepository>(DI.mutingsRepository);
renoteMutingsRepository = app.get<RenoteMutingsRepository>(DI.renoteMutingsRepository);
rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
roleService = app.get<RoleService>(RoleService);
});
afterAll(async () => {
@@ -290,60 +249,6 @@ describe('UserEntityService', () => {
expect(actual.achievements).toEqual(achievements);
});
test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => {
const me = await createUser();
const visibleRole = await createRole({ name: 'visible', isPublic: true });
const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true });
const privateRole = await createRole({ name: 'private', isPublic: false });
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true });
const unassignedRole = await createRole({ name: 'unassigned', isPublic: true });
await assignRole(me, visibleRole);
await assignRole(me, secondVisibleRole);
await assignRole(me, privateRole);
await assignRole(me, forcedRole);
await usersRepository.update(me.id, {
hiddenRoleIds: [
secondVisibleRole.id,
'unknownroleid',
unassignedRole.id,
privateRole.id,
forcedRole.id,
visibleRole.id,
secondVisibleRole.id,
],
});
const updatedMe = await usersRepository.findOneByOrFail({ id: me.id });
const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' });
expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]);
});
test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => {
const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]);
const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 });
const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 });
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 });
const moderatorRole = await createRole({ name: 'moderator', isModerator: true });
await assignRole(target, hiddenRole);
await assignRole(target, visibleRole);
await assignRole(target, forcedRole);
await assignRole(moderator, moderatorRole);
await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] });
const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id });
const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' });
const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' });
expect(normalView.roles.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
expect(normalView.badgeRoles?.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
expect(normalView.badgeRoles?.every(role => typeof role.id === 'string')).toBe(true);
expect(moderatorView.roles.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
expect(moderatorView.badgeRoles?.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
});
test('alsoKnownAs as string does not throw', async () => {
const me = await createUser();
const who = await createUser();

View File

@@ -11,16 +11,16 @@
},
"devDependencies": {
"@types/estree": "1.0.9",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"rollup": "4.62.2"
},
"dependencies": {
"i18n": "workspace:*",
"magic-string": "0.30.21",
"oxc-walker": "1.0.0",
"rolldown": "1.1.2",
"rolldown": "1.1.3",
"vite": "8.1.0"
}
}

View File

@@ -10,9 +10,6 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@vitejs/plugin-vue": "6.0.7",
"buraha": "0.0.1",
"frontend-shared": "workspace:*",
"i18n": "workspace:*",
@@ -21,42 +18,34 @@
"mfm-js": "0.26.0",
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.62.2",
"shiki": "4.2.0",
"shiki": "4.3.0",
"tinycolor2": "1.6.0",
"uuid": "14.0.0",
"vue": "3.5.38"
"uuid": "14.0.1",
"vue": "3.5.39"
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.5.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@tabler/icons-webfont": "3.35.0",
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.9",
"@types/micromatch": "4.0.10",
"@types/node": "26.0.0",
"@types/node": "26.0.1",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/coverage-v8": "4.1.9",
"@vue/runtime-core": "3.5.38",
"acorn": "8.17.0",
"cross-env": "10.1.0",
"@vitejs/plugin-vue": "6.0.7",
"@vue/runtime-core": "3.5.39",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.6",
"intersection-observer": "0.12.2",
"lightningcss": "1.32.0",
"micromatch": "4.0.8",
"msw": "2.14.6",
"prettier": "3.8.4",
"sass-embedded": "1.100.0",
"start-server-and-test": "3.0.11",
"tsx": "4.22.4",
"vite": "8.1.0",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.3.5",
"vue-eslint-parser": "10.4.1",
"vue-tsc": "3.3.5"

View File

@@ -1,7 +1,7 @@
import path from 'path';
import pluginVue from '@vitejs/plugin-vue';
import { defineConfig, type UserConfig } from 'vite';
import * as yaml from 'js-yaml';
import { load as loadYaml } from 'js-yaml';
import { promises as fsp } from 'fs';
import locales from 'i18n';
@@ -11,7 +11,7 @@ import pluginJson5 from './lib/vite-plugin-json5.js';
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n';
import { Features } from 'lightningcss';
const url = process.env.NODE_ENV === 'development' ? (yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null;
const url = process.env.NODE_ENV === 'development' ? (loadYaml(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null;
const host = url ? (new URL(url)).hostname : undefined;
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];

View File

@@ -8,10 +8,10 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "26.0.0",
"@types/node": "26.0.1",
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"eslint-plugin-vue": "10.9.2",
"vue-eslint-parser": "10.4.1"
},
@@ -23,8 +23,8 @@
"i18n": "workspace:*",
"json5": "2.2.3",
"misskey-js": "workspace:*",
"shiki": "4.2.0",
"shiki": "4.3.0",
"tinycolor2": "1.6.0",
"vue": "3.5.38"
"vue": "3.5.39"
}
}

View File

@@ -357,7 +357,6 @@ export function role(params: {
isPublic?: boolean,
isExplorable?: boolean,
asBadge?: boolean,
isPublicDisplayRequired?: boolean,
canEditMembersByModerator?: boolean,
usersCount?: number,
}, seed?: string): entities.Role {
@@ -381,7 +380,6 @@ export function role(params: {
isPublic: params.isPublic ?? true,
isExplorable: params.isExplorable ?? true,
asBadge: params.asBadge ?? true,
isPublicDisplayRequired: params.isPublicDisplayRequired ?? false,
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
usersCount: params.usersCount ?? 10,
preserveAssignmentOnMoveAccount: false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -61,7 +61,6 @@ it('Composition API (standard)', () => {
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const _sfc_main = /* @__PURE__ */ defineComponent({
@@ -179,7 +178,6 @@ export { index_photos as default };
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const index_photos = /* @__PURE__ */ defineComponent({

View File

@@ -9,8 +9,10 @@
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
"chromatic": "chromatic",
"test": "vitest --run --globals",
"test-and-coverage": "vitest --run --coverage --globals",
"test": "vitest --run --globals --config vitest.config.unit.ts",
"test-and-coverage": "vitest --run --coverage --globals --config vitest.config.unit.ts",
"test:e2e": "playwright test --config playwright.config.ts",
"test:browser": "vitest --run --globals --config vitest.config.browser.ts",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@@ -20,7 +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",
"@sentry/vue": "10.59.0",
"@sentry/vue": "10.62.0",
"@simplewebauthn/browser": "13.3.0",
"@syuilo/aiscript": "1.2.1",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
@@ -35,12 +37,10 @@
"chartjs-chart-matrix": "3.0.4",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "17.5.0",
"compare-versions": "6.1.1",
"cropperjs": "2.1.1",
"date-fns": "4.4.0",
"eventemitter3": "5.0.4",
"execa": "9.6.1",
"exifreader": "4.41.0",
"frontend-shared": "workspace:*",
"i18n": "workspace:*",
@@ -56,23 +56,23 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
"sanitize-html": "2.17.5",
"shiki": "4.2.0",
"seedrandom": "3.0.5",
"shiki": "4.3.0",
"textarea-caret": "3.1.0",
"three": "0.184.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"v-code-diff": "1.13.1",
"vue": "3.5.38",
"vue": "3.5.39",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/emoji-assets": "17.0.3",
"@misskey-dev/summaly": "5.5.1",
"@playwright/test": "1.61.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/pluginutils": "5.4.0",
"@storybook/addon-essentials": "8.6.18",
@@ -97,20 +97,23 @@
"@types/insert-text-at-cursor": "0.3.2",
"@types/matter-js": "0.20.2",
"@types/micromatch": "4.0.10",
"@types/node": "26.0.0",
"@types/node": "26.0.1",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.1",
"@types/seedrandom": "3.0.8",
"@types/textarea-caret": "3.0.4",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vue/compiler-core": "3.5.38",
"@vue/compiler-core": "3.5.39",
"astring": "1.9.0",
"chromatic": "17.7.2",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.9.2",
"execa": "9.6.1",
"happy-dom": "20.10.6",
"intersection-observer": "0.12.2",
"lightningcss": "1.32.0",
@@ -120,13 +123,13 @@
"msw-storybook-addon": "2.0.7",
"nodemon": "3.1.14",
"oxc-walker": "1.0.0",
"prettier": "3.8.4",
"playwright": "1.61.1",
"prettier": "3.9.1",
"react": "19.2.7",
"react-dom": "19.2.7",
"rolldown": "1.1.2",
"rolldown": "1.1.3",
"rollup-plugin-visualizer": "7.0.1",
"sass-embedded": "1.100.0",
"seedrandom": "3.0.5",
"storybook": "10.4.6",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.22.4",

View File

@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './test/e2e',
reporter: 'list',
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 10_000,
},
outputDir: './test/e2e/artifacts',
use: {
locale: 'en-US',
baseURL: 'http://localhost:61812',
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'off',
},
projects: [{
name: 'chromium',
use: {
browserName: 'chromium',
},
}],
});

View File

@@ -99,11 +99,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
// タッチデバイスでCSSの:hoverを機能させる
window.document.addEventListener('touchend', () => {}, { passive: true });
// URLに#pswpを含む場合は取り除く
if (window.location.hash === '#pswp') {
window.history.replaceState(null, '', window.location.href.replace('#pswp', ''));
}
// 一斉リロード
reloadChannel.addEventListener('message', path => {
if (path !== null) window.location.href = path;

View File

@@ -21,8 +21,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else>
<div style="font-size: 13px; margin-bottom: 4px;">Type "ai-chan-kawaii" to pass captcha</div>
<input v-model="testcaptchaInput" data-cy-testcaptcha-input/>
<button type="button" data-cy-testcaptcha-submit @click="testcaptchaSubmit">Submit</button>
<input v-model="testcaptchaInput" data-testid="testcaptcha-input"/>
<button type="button" data-testid="testcaptcha-submit" @click="testcaptchaSubmit">Submit</button>
</div>
</div>
<div v-else ref="captchaEl"></div>

View File

@@ -31,8 +31,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
<MkButton v-if="showOkButton" data-testid="modal-dialog-ok" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-testid="modal-dialog-cancel" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer>
<template #header>
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-testid="folder-header" @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div :class="$style.headerTextMain">

View File

@@ -0,0 +1,487 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="rootEl"
:class="$style.root"
@pointerdown="onPointerdown"
@pointermove.passive="onPointermove"
@pointerup="onPointerup"
@touchstart="onTouchstart"
@touchmove.passive="onTouchmove"
@wheel="onWheel"
>
<div
:class="[$style.transformer, { [$style.transition]: enableTransition }]"
:style="{ translate: `${transform.x}px ${transform.y}px`, scale: transform.scale }"
@transitionend.self="enableTransition = false"
@transitioncancel.self="enableTransition = false"
>
<img
v-if="!originalImageLoaded || !thumbnailImageLoaded"
:class="[$style.image, $style.thumbnail]"
:src="image.thumbnailUrl"
:width="image.width"
:height="image.height"
:style="{ width: `${imageRenderingSize.width}px`, height: `${imageRenderingSize.height}px` }"
draggable="false"
@load="thumbnailImageLoaded = true"
>
<img
v-if="activated"
ref="imageEl"
:class="[$style.image, $style.original]"
:src="image.url"
:width="image.width"
:height="image.height"
:style="{ width: `${imageRenderingSize.width}px`, height: `${imageRenderingSize.height}px` }"
draggable="false"
@load="originalImageLoaded = true"
>
</div>
<div v-if="activated && !originalImageLoaded" :class="$style.loading">
<MkLoading/>
</div>
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
<div :class="$style.footerText">
{{ image.filename }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { makeDoubleTapDetector } from '@/utility/double-tap.js';
import { calculateSourceTransform } from '@/components/MkImageGallery.utils.js';
import { deviceKind } from '@/utility/device-kind.js';
export type Image = {
id: string;
url: string;
thumbnailUrl: string;
width: number;
height: number;
filename?: string;
sourceElement?: HTMLElement;
};
const props = withDefaults(defineProps<{
image: Image;
activated: boolean;
}>(), {
});
const emit = defineEmits<{
(ev: 'close'): void;
(ev: 'horizontalSwipe', offset: number): void;
(ev: 'next'): void;
(ev: 'prev'): void;
(ev: 'cancelHorizontalSwipe'): void;
}>();
const rootEl = useTemplateRef('rootEl');
const imageEl = useTemplateRef('imageEl');
const originalImageLoaded = ref(false);
const thumbnailImageLoaded = ref(false);
const enableTransition = ref(false);
const infoShowing = ref(false);
onMounted(() => {
rootEl.value.offsetHeight; // reflow
infoShowing.value = true;
});
const padding = deviceKind === 'smartphone' ? 0 : 30;
const ANIMATION_DURATION = 200;
const headerAreaSize = 0;
const footerAreaSize = 30;
// maxからはみ出す場合は縮小、maxに満たない場合は拡大する(contain)
function calcImageRenderingSize(image: Image) {
const maxWidth = window.innerWidth - padding * 2;
const maxHeight = window.innerHeight - headerAreaSize - footerAreaSize - padding * 2;
const widthRatio = maxWidth / image.width;
const heightRatio = maxHeight / image.height;
const ratio = widthRatio < heightRatio ? widthRatio : heightRatio;
const width = image.width * ratio;
const height = image.height * ratio;
return { width, height };
}
const imageRenderingSize = calcImageRenderingSize(props.image);
const imageRenderingRect = {
left: (window.innerWidth - imageRenderingSize.width) / 2,
top: headerAreaSize + (window.innerHeight - headerAreaSize - footerAreaSize - imageRenderingSize.height) / 2,
width: imageRenderingSize.width,
height: imageRenderingSize.height,
};
const transform = ref({ x: 0, y: 0, scale: 1 });
// 元のimg要素の位置・サイズ(とobject-fitの設定値)を取得して、そこからneutralの位置にアニメーションするためのscaleとtranslationを計算する
function getScaleAndTranslationForSourceElement(): { x: number; y: number; scale: number } {
const sourceElement = props.image.sourceElement;
if (sourceElement == null) return { x: 0, y: 0, scale: 1 };
return calculateSourceTransform({
fit: window.getComputedStyle(sourceElement).objectFit,
imageRenderingRect,
sourceRect: sourceElement.getBoundingClientRect(),
});
}
if (props.image.sourceElement != null && props.activated) {
const sourceTransform = getScaleAndTranslationForSourceElement();
transform.value.scale = sourceTransform.scale;
transform.value.x = sourceTransform.x;
transform.value.y = sourceTransform.y;
}
const isZooming = ref(false);
function zoomInTo(x: number, y: number, factor = 1.1, withAnimation = false) {
const newScale = transform.value.scale * factor;
isZooming.value = true;
const rect = rootEl.value.getBoundingClientRect();
const offsetX = x - rect.left;
const offsetY = y - rect.top;
const newTranslationX = offsetX - (offsetX - transform.value.x) * factor;
const newTranslationY = offsetY - (offsetY - transform.value.y) * factor;
if (withAnimation) {
enableTransition.value = true;
}
transform.value.x = newTranslationX;
transform.value.y = newTranslationY;
transform.value.scale = newScale;
}
function resetToNeutral() {
isZooming.value = false;
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.scale = 1;
transform.value.x = 0;
transform.value.y = 0;
}
function onWheel(event: WheelEvent) {
event.preventDefault();
const delta = event.deltaY;
const scaleFactor = 1.1;
const scale = delta > 0 ? 1 / scaleFactor : scaleFactor;
const newScale = transform.value.scale * scale;
if (newScale < 1) {
transform.value.scale = 1;
transform.value.x = 0;
transform.value.y = 0;
isZooming.value = false;
return;
}
zoomInTo(event.clientX, event.clientY, scale);
}
function onZoomGesture(ev: { delta: number; centerX: number; centerY: number }) {
zoomInTo(ev.centerX, ev.centerY, 1 + ev.delta / 200);
}
function onZoomGestureEnd() {
if (transform.value.scale < 1) {
isZooming.value = false;
resetToNeutral();
}
}
// ズーム中、ドラッグされたら画像を移動する
let isDragging = false;
let lastX = 0;
let lastY = 0;
let currentPointerId: number | null = null;
let currentPointerStartOffset = { x: 0, y: 0 };
let isVerticalSwiping = false;
let isHorizontalSwiping = false;
let verticalSwipeDelta = 0;
let horizontalSwipeDelta = 0;
const pointerEventCache = new Map<number, PointerEvent>();
let pointerVec = { x: 0, y: 0 };
function onPointerdown(ev: PointerEvent) {
pointerEventCache.set(ev.pointerId, ev);
rootEl.value.setPointerCapture(ev.pointerId);
isDragging = true;
lastX = ev.clientX;
lastY = ev.clientY;
pointerVec = { x: 0, y: 0 };
if (currentPointerId == null) {
currentPointerId = ev.pointerId;
currentPointerStartOffset = {
x: ev.clientX,
y: ev.clientY,
};
}
}
let prevTwoTouchPointsDistance = 0;
let lastMoveTimeStamp = 0;
function onPointermove(ev: PointerEvent) {
const currentTime = performance.now();
const dt = currentTime - lastMoveTimeStamp;
lastMoveTimeStamp = currentTime;
if (pointerEventCache.size === 0) {
return;
}
pointerEventCache.set(ev.pointerId, ev);
if (pointerEventCache.size > 1) { // 2本指での操作
pointerVec = { x: 0, y: 0 };
currentPointerId = null;
isVerticalSwiping = false;
isHorizontalSwiping = false;
const a = Array.from(pointerEventCache.values())[0];
const b = Array.from(pointerEventCache.values())[1];
const distance = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
if (prevTwoTouchPointsDistance > 0) {
const delta = distance - prevTwoTouchPointsDistance;
onZoomGesture({ delta, centerX: (a.clientX + b.clientX) / 2, centerY: (a.clientY + b.clientY) / 2 });
}
prevTwoTouchPointsDistance = distance;
return;
}
prevTwoTouchPointsDistance = 0;
if (currentPointerId === ev.pointerId) {
const deltaX = ev.clientX - lastX;
const deltaY = ev.clientY - lastY;
if (isZooming.value) {
transform.value.x += deltaX;
transform.value.y += deltaY;
} else {
if (isVerticalSwiping) {
transform.value.y += deltaY;
verticalSwipeDelta += deltaY;
} else if (isHorizontalSwiping) {
horizontalSwipeDelta = ev.clientX - currentPointerStartOffset.x;
emit('horizontalSwipe', horizontalSwipeDelta);
} else {
const isVerticalVector = Math.abs(deltaY) > Math.abs(deltaX);
if (isVerticalVector) {
isVerticalSwiping = true;
} else {
isHorizontalSwiping = true;
}
}
}
if (dt > 0) {
pointerVec = { x: deltaX / dt, y: deltaY / dt };
}
lastX = ev.clientX;
lastY = ev.clientY;
}
return false;
}
function onPointerup(ev: PointerEvent) {
pointerEventCache.delete(ev.pointerId);
rootEl.value.releasePointerCapture(ev.pointerId);
prevTwoTouchPointsDistance = 0;
isDragging = false;
if (currentPointerId === ev.pointerId) {
currentPointerId = null;
if (isVerticalSwiping) {
const shouldCloseByUpwardSwipe = verticalSwipeDelta < -200 || (verticalSwipeDelta < 0 && pointerVec.y < -3); // 上の方で離された、または上に向かって強めに弾かれた
const shouldCloseByDownwardSwipe = verticalSwipeDelta > 200 || (verticalSwipeDelta > 0 && pointerVec.y > 3); // 下の方で離された、または下に向かって強めに弾かれた
if (shouldCloseByUpwardSwipe || shouldCloseByDownwardSwipe) {
emit('close');
infoShowing.value = false;
const sourceTransform = getScaleAndTranslationForSourceElement();
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.x = sourceTransform.x;
transform.value.y = sourceTransform.y;
transform.value.scale = sourceTransform.scale;
return;
}
resetToNeutral();
} else if (isHorizontalSwiping) {
const shouldNext = horizontalSwipeDelta < -150 || (horizontalSwipeDelta < 0 && pointerVec.x < -1); // 左の方で離された、または左に向かって強めに弾かれた
const shouldPrev = horizontalSwipeDelta > 150 || (horizontalSwipeDelta > 0 && pointerVec.x > 1); // 右の方で離された、または右に向かって強めに弾かれた
if (shouldNext) {
emit('next');
} else if (shouldPrev) {
emit('prev');
} else {
emit('cancelHorizontalSwipe');
}
}
}
isVerticalSwiping = false;
isHorizontalSwiping = false;
onZoomGestureEnd();
}
const doubleTapDetector = makeDoubleTapDetector((ev) => {
ev.preventDefault();
ev.stopPropagation();
pointerVec = { x: 0, y: 0 };
if (isZooming.value) {
isZooming.value = false;
resetToNeutral();
} else {
isZooming.value = true;
zoomInTo(ev.touches[0].clientX, ev.touches[0].clientY, 2, true);
}
});
function onTouchstart(ev: TouchEvent) {
ev.preventDefault();
ev.stopPropagation();
doubleTapDetector.onTouchstart(ev);
}
function onTouchmove(ev: TouchEvent) {
doubleTapDetector.onTouchmove(ev);
}
let rafHandle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
let latestInertiaTimeStamp = 0;
//#region inertia
const inertiaFactor = 0.9;
function updateInertia(timeStamp: number) {
rafHandle = window.requestAnimationFrame(updateInertia);
const timeDelta = timeStamp - latestInertiaTimeStamp;
latestInertiaTimeStamp = timeStamp;
if (isDragging) return;
if (!isZooming.value) return;
if (Math.abs(pointerVec.x) < 0.01 && Math.abs(pointerVec.y) < 0.01) return;
transform.value.x += pointerVec.x * timeDelta;
transform.value.y += pointerVec.y * timeDelta;
pointerVec.x *= inertiaFactor ** (timeDelta / 16.67);
pointerVec.y *= inertiaFactor ** (timeDelta / 16.67);
}
onMounted(() => {
rafHandle = window.requestAnimationFrame(updateInertia);
});
onBeforeUnmount(() => {
if (rafHandle != null) window.cancelAnimationFrame(rafHandle);
});
//#endregion
watch(thumbnailImageLoaded, () => {
if (props.image.sourceElement != null && props.activated) {
props.image.sourceElement.style.visibility = 'hidden';
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.x = 0;
transform.value.y = 0;
transform.value.scale = 1;
}
}, { once: true });
</script>
<style lang="scss" module>
.root {
position: absolute;
touch-action: none;
width: 100%;
height: 100%;
}
.image {
display: block;
-webkit-touch-callout: none;
user-select: none;
position: absolute;
top: v-bind("headerAreaSize + 'px'");
left: 0;
right: 0;
bottom: v-bind("footerAreaSize + 'px'");
margin: auto;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
display: grid;
place-items: center;
pointer-events: none;
}
.transformer {
width: 100%;
height: 100%;
transform-origin: left top;
}
.transition {
transition: translate 200ms ease, scale 200ms ease;
}
.footer {
position: absolute;
bottom: -30px;
left: 0;
width: 100%;
height: 30px;
background: var(--MI_THEME-panel);
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
font-size: 85%;
color: var(--MI_THEME-fg);
opacity: 0;
transition: opacity 200ms ease, bottom 200ms ease;
}
.footer.infoShowing {
bottom: 0;
opacity: 1;
}
.footerText {
}
</style>

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type Size = {
width: number;
height: number;
};
type Rect = Size & {
left: number;
top: number;
};
export function calculateSourceTransform({
fit,
imageRenderingRect,
sourceRect,
}: {
fit: string;
imageRenderingRect: Rect;
sourceRect: Rect;
}): { x: number; y: number; scale: number } {
const scale = fit === 'cover'
? Math.max(sourceRect.width / imageRenderingRect.width, sourceRect.height / imageRenderingRect.height)
: Math.min(sourceRect.width / imageRenderingRect.width, sourceRect.height / imageRenderingRect.height);
const sourceImageWidth = imageRenderingRect.width * scale;
const sourceImageHeight = imageRenderingRect.height * scale;
const sourceImageLeft = sourceRect.left + (sourceRect.width - sourceImageWidth) / 2;
const sourceImageTop = sourceRect.top + (sourceRect.height - sourceImageHeight) / 2;
return {
x: sourceImageLeft - imageRenderingRect.left * scale,
y: sourceImageTop - imageRenderingRect.top * scale,
scale,
};
}

View File

@@ -0,0 +1,204 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<!-- durationは子Itemコンポーネントがフェードイン/アウトするdurationと合わせる -->
<Transition
:enterActiveClass="prefer.s.animation ? $style.transition_root_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_root_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_root_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_root_leaveTo : ''"
:duration="{ enter: prefer.s.animation ? openAnimDuration : 0, leave: prefer.s.animation ? closeAnimDuration : 0 }"
appear
@afterLeave="onAfterLeave"
>
<!-- v-ifを使うとfalseになったとき(transitionが行われている間)子コンポーネントの更新が停止するのか子コンポーネントがアニメーションされなくなる -->
<div v-show="showing" ref="rootEl" :class="$style.root" :style="{ zIndex }">
<div :class="[$style.bg]" class="_modalBg"></div>
<div ref="mainEl" :class="$style.main">
<div
ref="itemsEl"
:class="[$style.items, { [$style.itemsTransition]: enableSlideTransition }]"
:style="{ translate: `${imagesOffset}px 0` }"
@transitionend.self="onSlideTransitionFinished"
@transitioncancel.self="onSlideTransitionFinished"
>
<div v-for="(image, i) in images" :key="image.url" ref="itemEl" :class="$style.item">
<XItem
:image="image"
:activated="activatedIndexes.has(i)"
@close="onItemClose"
@horizontalSwipe="onHorizontalSwipe"
@prev="onPrev"
@next="onNext"
@cancelHorizontalSwipe="onCancelHorizontalSwipe"
/>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import XItem from './MkImageGallery.item.vue';
import type { Image } from './MkImageGallery.item.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
defaultIndex?: number;
images: Image[];
}>(), {
});
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
const activatedIndexes = ref(new Set<number>());
const currentIndex = ref(props.defaultIndex ?? 0);
watch(currentIndex, (newIndex) => {
activatedIndexes.value.add(newIndex);
}, { immediate: true });
watch(currentIndex, (newIndex) => {
for (let i = 0; i < props.images.length; i++) {
props.images[i].sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
}
}, { immediate: false });
const openAnimDuration = 200;
const closeAnimDuration = 200;
const slideAnimDuration = 300;
const zIndex = os.claimZIndex('high');
const showing = ref(true);
const screenWidth = ref(window.innerWidth);
const imagesOffset = ref(currentIndex.value * -window.innerWidth);
const enableSlideTransition = ref(false);
let currentScrollLeft = imagesOffset.value;
function onHorizontalSwipe(offset: number) {
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
imagesOffset.value = currentScrollLeft + (offset / 3);
} else if (currentIndex.value === props.images.length - 1 && offset < 0) { // これ以上進めない
imagesOffset.value = currentScrollLeft + (offset / 3);
} else {
imagesOffset.value = currentScrollLeft + offset;
}
}
function scrollToCurrentIndex() {
const targetOffset = currentIndex.value * -screenWidth.value;
currentScrollLeft = targetOffset;
if (!prefer.s.animation || imagesOffset.value === targetOffset) {
enableSlideTransition.value = false;
imagesOffset.value = targetOffset;
return;
}
enableSlideTransition.value = true;
imagesOffset.value = targetOffset;
}
function onSlideTransitionFinished(ev: TransitionEvent) {
if (ev.propertyName !== 'translate') return;
enableSlideTransition.value = false;
}
function onCancelHorizontalSwipe() {
scrollToCurrentIndex();
}
function onNext() {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value++;
}
scrollToCurrentIndex();
}
function onPrev() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
scrollToCurrentIndex();
}
function onItemClose() {
showing.value = false;
}
function onAfterLeave() {
for (const image of props.images) {
if (image.sourceElement != null) {
image.sourceElement.style.visibility = '';
}
}
emit('closed');
}
</script>
<style lang="scss" module>
.transition_root_enterActive {
> .bg {
transition: opacity v-bind("openAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
}
}
.transition_root_leaveActive {
> .bg {
transition: opacity v-bind("closeAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
}
}
.transition_root_enterFrom {
> .bg {
opacity: 0;
}
}
.transition_root_leaveTo {
> .bg {
opacity: 0;
}
}
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.bg {
}
.main {
position: absolute;
}
.items {
position: absolute;
display: flex;
width: calc(v-bind("screenWidth + 'px'") * v-bind("images.length"));
height: 100dvh;
overflow: clip;
contain: strict;
}
.itemsTransition {
pointer-events: none;
transition: translate v-bind("slideAnimDuration + 'ms'") cubic-bezier(0.45, 0, 0.55, 1);
}
.item {
width: 100dvw;
height: 100dvh;
overflow: clip;
contain: strict;
flex-shrink: 0;
}
</style>

View File

@@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:src="src ?? undefined"
:title="title ?? undefined"
:alt="alt ?? undefined"
:data-marker="marker ?? undefined"
loading="eager"
decoding="async"
draggable="false"
@@ -55,7 +56,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.Cypress != null);
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
@@ -87,8 +88,8 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
import { genId } from '@/utility/id.js';
import { render } from 'buraha';
import { genId } from '@/utility/id.js';
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
@@ -110,6 +111,7 @@ const props = withDefaults(defineProps<{
cover?: boolean;
forceBlurhash?: boolean;
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
marker?: string;
}>(), {
transition: null,
src: null,

View File

@@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:height="image.properties.height"
:style="hide ? 'filter: brightness(0.7);' : null"
:class="$style.image"
:marker="marker"
/>
<div
v-else-if="prefer.s.dataSaver.media || hide"
@@ -42,6 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:alt="image.comment || image.name"
:title="image.comment || image.name"
:class="$style.image"
:data-marker="marker"
/>
</component>
<template v-if="hide">
@@ -85,6 +87,7 @@ const props = withDefaults(defineProps<{
cover?: boolean;
disableImageLink?: boolean;
controls?: boolean;
marker?: string;
}>(), {
cover: false,
disableImageLink: false,

View File

@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :marker="`${markerId}:${media.id}`" :disableImageLink="true" :class="$style.media" :image="media" :raw="raw" @click="openGallery(media.id)"/>
</template>
</div>
</div>
@@ -29,11 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
import 'photoswipe/style.css';
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
@@ -41,6 +38,7 @@ import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { focusParent } from '@/utility/focus.js';
import { prefer } from '@/preferences.js';
import { genId } from '@/utility/id.js';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@@ -48,18 +46,8 @@ const props = defineProps<{
}>();
const gallery = useTemplateRef('gallery');
const pswpZIndex = os.claimZIndex('middle');
window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
let lightbox: PhotoSwipeLightbox | null = null;
let activeEl: HTMLElement | null = null;
const popstateHandler = (): void => {
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
lightbox.pswp.close();
}
};
const markerId = genId();
async function calcAspectRatio() {
if (!gallery.value) return;
@@ -96,121 +84,9 @@ onMounted(() => {
calcAspectRatio();
if (gallery.value == null) return; // TSを黙らすため
lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
.filter(media => {
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
})
.map(media => {
const item = {
src: media.url,
w: media.properties.width,
h: media.properties.height,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
alt: media.comment || media.name,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
comment: media.comment || media.name,
};
if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w];
}
return item;
}),
gallery: gallery.value,
mainClass: 'pswp',
children: '.image',
thumbSelector: '.image',
loop: false,
padding: window.innerWidth > 500 ? {
top: 32,
bottom: 90,
left: 32,
right: 32,
} : {
top: 0,
bottom: 78,
left: 0,
right: 0,
},
imageClickAction: 'close',
tapAction: 'close',
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});
lightbox.addFilter('itemData', (itemData) => {
// element is children
const { element } = itemData;
const id = element?.dataset.id;
const file = props.mediaList.find(media => media.id === id);
if (!file) return itemData;
itemData.src = file.url;
itemData.w = Number(file.properties.width);
itemData.h = Number(file.properties.height);
if (file.properties.orientation != null && file.properties.orientation >= 5) {
[itemData.w, itemData.h] = [itemData.h, itemData.w];
}
itemData.msrc = file.thumbnailUrl ?? undefined;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemData.alt = file.comment || file.name;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemData.comment = file.comment || file.name;
itemData.thumbCropped = true;
return itemData;
});
lightbox.on('uiRegister', () => {
lightbox?.pswp?.ui?.registerElement({
name: 'altText',
className: 'pswp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pswp) => {
const textBox = window.document.createElement('p');
textBox.className = 'pswp__alt-text _acrylic';
el.appendChild(textBox);
pswp.on('change', () => {
textBox.textContent = pswp.currSlide?.data.comment;
});
},
});
});
lightbox.on('afterInit', () => {
activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
focusParent(activeEl, true, true);
lightbox?.pswp?.element?.focus({
preventScroll: true,
});
window.history.pushState(null, '', '#pswp');
});
lightbox.on('destroy', () => {
focusParent(activeEl, true, false);
activeEl = null;
if (window.location.hash === '#pswp') {
window.history.back();
}
});
window.addEventListener('popstate', popstateHandler);
lightbox.init();
});
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler);
lightbox?.destroy();
lightbox = null;
activeEl = null;
});
const previewable = (file: Misskey.entities.DriveFile): boolean => {
@@ -219,11 +95,23 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
const openGallery = () => {
if (props.mediaList.filter(media => previewable(media)).length > 0) {
lightbox?.loadAndOpen(0);
}
};
async function openGallery(id: string) {
const images = props.mediaList.filter(media => previewable(media)).map(media => ({
id: media.id,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
filename: media.name,
sourceElement: markRaw(gallery.value?.querySelector(`[data-marker="${markerId}:${media.id}"]`)),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
defaultIndex: images.findIndex(image => image.id === id),
images: images,
}, {
closed: () => dispose(),
});
}
defineExpose({
openGallery,
@@ -316,6 +204,7 @@ defineExpose({
.media {
overflow: hidden; // clipにするとバグる
border-radius: 8px;
cursor: zoom-in;
}
@container (min-width: 500px) {
@@ -331,42 +220,4 @@ defineExpose({
}
}
}
:global(.pswp) {
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
--pswp-bg: var(--MI_THEME-modalBg) !important;
}
</style>
<style lang="scss">
.pswp__bg {
background: var(--MI_THEME-modalBg);
backdrop-filter: var(--MI-modalBgFilter);
}
.pswp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 75%;
max-width: 800px;
}
.pswp__alt-text {
color: var(--MI_THEME-fg);
margin: 0 auto;
text-align: center;
padding: var(--MI-margin);
border-radius: var(--MI-radius);
max-height: 8em;
overflow-y: auto;
text-shadow: var(--MI_THEME-bg) 0 0 10px, var(--MI_THEME-bg) 0 0 3px, var(--MI_THEME-bg) 0 0 3px;
white-space: pre-line;
}
</style>

View File

@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened"
>
<div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div data-testid="bg" :data-test-is-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick">
<slot :max-height="maxHeight" :type="type"></slot>
</div>

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModal ref="modal" v-slot="{ type }" :preferType="deviceKind === 'smartphone' ? 'drawer' : 'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
<div ref="rootEl" :class="[$style.root, type === 'drawer' ? $style.asDrawer : null]" :style="{ width: type === 'drawer' ? '' : `${width}px`, height: type === 'drawer' ? '' : `min(${height}px, 100%)` }">
<div :class="$style.header">
<button v-if="withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withCloseButton" :class="$style.headerButton" class="_button" data-testid="modal-window-close" @click="emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title">
<slot name="header"></slot>
</span>

View File

@@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.noteHeaderUsername">
<MkAcct :user="appearNote.user"/>
</div>
<div v-if="badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
</div>
</div>
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
@@ -251,7 +251,6 @@ import { Paginator } from '@/utility/paginator.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import number from '@/filters/number.js';
import { DI } from '@/di.js';
import { $i } from '@/i.js';
import type { Keymap } from '@/utility/hotkey.js';
// コンポーネント外部の依存関係
@@ -333,14 +332,6 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
// MkNoteDetailed固有
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const badgeRoles = computed(() => {
const roles = appearNote.user.badgeRoles;
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
return roles.filter(role => !hiddenRoleIds.has(role.id));
});
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
limit: 10,

View File

@@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="badgeRoles" :class="$style.badgeRoles">
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
<div :class="$style.info">
<div v-if="mock">
@@ -35,26 +35,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { inject } from 'vue';
import * as Misskey from 'misskey-js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import { DI } from '@/di.js';
const props = defineProps<{
defineProps<{
note: Misskey.entities.Note;
}>();
const badgeRoles = computed(() => {
const roles = props.note.user.badgeRoles;
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
return roles.filter(role => !hiddenRoleIds.has(role.id));
});
const mock = inject(DI.mock, false);
</script>

View File

@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
<div style="padding: 0 0 16px 0; text-align: center;">
<img src="/client-assets/locked_with_key_3d.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;">
<img src="/fluent-emoji/1f510.png" alt="🔐" style="display: block; margin: 0 auto; width: 48px;">
<div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
</div>

View File

@@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
<button ref="otherSettingsButton" v-tooltip="i18n.ts.other" class="_button" :class="$style.headerRightItem" @click="showOtherSettings"><i class="ti ti-dots"></i></button>
<button ref="submitButtonEl" v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
<button ref="submitButtonEl" v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-testid="post-form-submit" @click="post">
<div :class="$style.submitInner">
<template v-if="posted"></template>
<template v-else-if="posting"><MkEllipsis/></template>
@@ -77,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<div v-if="targetChannel" :class="$style.colorBar" :style="{ background: targetChannel.color }"></div>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"></textarea>
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-testid="post-form-text" @keydown="onKeydown" @keyup="onKeyup" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"></textarea>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps_m">
<MkInput v-model="q_name" data-cy-server-name>
<MkInput v-model="q_name" data-testid="server-setup-server-name">
<template #label>{{ i18n.ts.instanceName }}</template>
</MkInput>
@@ -186,7 +186,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<template #footer>
<MkButton gradate large rounded data-cy-server-setup-wizard-apply style="margin: 0 auto;" @click="applySettings">
<MkButton gradate large rounded data-testid="server-setup-wizard-apply" style="margin: 0 auto;" @click="applySettings">
<i class="ti ti-check"></i> {{ i18n.ts._serverSetupWizard.applyTheseSettings }}
</MkButton>
</template>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper" data-cy-signin-page-input>
<div :class="$style.wrapper" data-testid="signin-page-input">
<div :class="$style.root">
<div :class="$style.avatar">
<i class="ti ti-user"></i>
@@ -32,11 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- username入力 -->
<form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username>
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-testid="signin-username">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
<MkButton type="submit" large primary rounded style="margin: 0 auto;" data-cy-signin-page-input-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton type="submit" large primary rounded style="margin: 0 auto;" data-testid="signin-page-input-continue">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</form>
<!-- パスワードレスログイン -->

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper" data-cy-signin-page-password>
<div :class="$style.wrapper" data-testid="signin-page-password">
<div class="_gaps" :class="$style.root">
<div :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined }"></div>
<div :class="$style.welcomeBackMessage">
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<!-- ブラウザ オートコンプリート用 -->
<input type="hidden" name="username" autocomplete="username" :value="user.username">
<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required autofocus data-cy-signin-password>
<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required autofocus data-testid="signin-password">
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput>
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" provider="testcaptcha" :sitekey="null"/>
</div>
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-testid="signin-page-password-continue">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</form>
</div>
</div>

View File

@@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
<form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required data-cy-signup-invitation-code>
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required data-testid="signup-invitation-code">
<template #label>{{ i18n.ts.invitationCode }}</template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" autocomplete="username" required data-cy-signup-username @update:modelValue="onChangeUsername">
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" autocomplete="username" required data-testid="signup-username" @update:modelValue="onChangeUsername">
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
@@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="usernameState === 'max-range'" style="color: var(--MI_THEME-error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
</template>
</MkInput>
<MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail">
<MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-testid="signup-email" @update:modelValue="onChangeEmail">
<template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix><i class="ti ti-mail"></i></template>
<template #caption>
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="emailState === 'error'" style="color: var(--MI_THEME-error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
</template>
</MkInput>
<MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
<MkInput v-model="password" type="password" autocomplete="new-password" required data-testid="signup-password" @update:modelValue="onChangePassword">
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
@@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="passwordStrength == 'high'" style="color: var(--MI_THEME-success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-testid="signup-password-retype" @update:modelValue="onChangePasswordRetype">
<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
@@ -67,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha" :sitekey="null"/>
<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-testid="signup-submit" style="margin: 0 auto;">
<template v-if="submitting">
<MkLoading :em="true" :colored="false"/>
</template>

View File

@@ -49,14 +49,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<a href="https://misskey-hub.net/docs/for-users/onboarding/warning/" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
<MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch>
<MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-testid="signup-rules-notes-agree" @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div>
<div class="_buttonsCenter">
<MkButton inline rounded @click="emit('cancel')">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary rounded gradate :disabled="!agreed" data-cy-signup-rules-continue @click="emit('done')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton inline primary rounded gradate :disabled="!agreed" data-testid="signup-rules-continue" @click="emit('done')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.buttonChecked]: checked,
[$style.buttonDisabled]: props.disabled
}"
data-cy-switch-toggle
data-testid="switch-toggle"
@click.prevent.stop="toggle"
>
<div :class="{ [$style.knob]: true, [$style.knobChecked]: checked }"></div>

View File

@@ -17,11 +17,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</FormSlot>
<MkInput v-model="name" :max="30" manualSave data-cy-user-setup-user-name>
<MkInput v-model="name" :max="30" manualSave data-testid="user-setup-user-name">
<template #label>{{ i18n.ts._profile.name }}</template>
</MkInput>
<MkTextarea v-model="description" :max="500" tall manualSave data-cy-user-setup-user-description>
<MkTextarea v-model="description" :max="500" tall manualSave data-testid="user-setup-user-description">
<template #label>{{ i18n.ts._profile.description }}</template>
</MkTextarea>

View File

@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="dialog"
:width="500"
:height="550"
data-cy-user-setup
data-testid="user-setup-dialog"
@close="close(true)"
@closed="emit('closed')"
>
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--MI_THEME-accent);"></i>
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div>
<div>{{ i18n.ts._initialAccountSetting.letsStartAccountSetup }}</div>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-testid="user-setup-continue" @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton style="margin: 0 auto;" transparent rounded @click="later(true)">{{ i18n.ts.later }}</MkButton>
</div>
</div>
@@ -52,8 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton rounded data-testid="user-setup-back" @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-testid="user-setup-continue" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
@@ -67,8 +67,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton rounded data-testid="user-setup-back" @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-testid="user-setup-continue" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
@@ -81,8 +81,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.pageFooter">
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate style="" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton rounded data-testid="user-setup-back" @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate style="" data-testid="user-setup-continue" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
@@ -96,8 +96,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="padding: 0 16px;">{{ i18n.tsx._initialAccountSetting.pushNotificationDescription({ name: instance.name ?? host }) }}</div>
<MkPushNotificationAllowButton primary showOnlyToRegister style="margin: 0 auto;"/>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton rounded data-testid="user-setup-back" @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton primary rounded gradate data-testid="user-setup-continue" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
@@ -112,11 +112,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
<div>{{ i18n.tsx._initialAccountSetting.youCanContinueTutorial({ name: instance.name ?? host }) }}</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
<MkButton rounded primary gradate data-cy-user-setup-continue @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton rounded primary gradate data-testid="user-setup-continue" @click="launchTutorial()">{{ i18n.ts._initialAccountSetting.startTutorial }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
<div class="_buttonsCenter">
<MkButton rounded data-cy-user-setup-back @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton rounded primary data-cy-user-setup-continue @click="setupComplete()">{{ i18n.ts.close }}</MkButton>
<MkButton rounded data-testid="user-setup-back" @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
<MkButton rounded primary data-testid="user-setup-continue" @click="setupComplete()">{{ i18n.ts.close }}</MkButton>
</div>
</div>
</div>

View File

@@ -24,9 +24,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo>
</div>
<div class="_gaps_s" :class="$style.mainActions">
<MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
<MkButton :class="$style.mainAction" full rounded gradate data-testid="signup" style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
<MkButton :class="$style.mainAction" full rounded type="a" target="_blank" rel="noopener" href="https://misskey-hub.net/servers/">{{ i18n.ts.exploreOtherServers }}</MkButton>
<MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
<MkButton :class="$style.mainAction" full rounded data-testid="signin" @click="signin()">{{ i18n.ts.login }}</MkButton>
</div>
</div>
</div>

View File

@@ -7,10 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root" class="_gaps_s">
<template v-if="edit">
<header :class="$style.editHeader">
<MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
<MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-testid="widget-select">
<template #label>{{ i18n.ts.selectWidget }}</template>
</MkSelect>
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline primary data-testid="widget-add" @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton>
</header>
<MkDraggable
@@ -21,9 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only
@update:modelValue="v => emit('updateWidgets', v)"
>
<template #default="{ item }">
<div :class="[$style.widget, $style.customizeContainer]" data-cy-customize-container>
<div :class="[$style.widget, $style.customizeContainer]" data-testid="customize-container">
<button :class="$style.customizeContainerConfig" class="_button" @click.prevent.stop="configWidget(item.id)"><i class="ti ti-settings"></i></button>
<button :class="$style.customizeContainerRemove" data-cy-customize-container-remove class="_button" @click.prevent.stop="removeWidget(item)"><i class="ti ti-x"></i></button>
<button :class="$style.customizeContainerRemove" data-testid="customize-container-remove" class="_button" @click.prevent.stop="removeWidget(item)"><i class="ti ti-x"></i></button>
<component :is="`widget-${item.name}`" :ref="(el: any) => widgetRefs[item.id] = el" :class="$style.customizeContainerHandleWidget" :widget="item" @updateProps="updateWidget(item.id, $event)"/>
</div>
</template>

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref, watch, onUnmounted } from 'vue';
import { ref, watch, onBeforeUnmount } from 'vue';
import type { Ref } from 'vue';
export function useTooltip(
@@ -100,7 +100,15 @@ export function useTooltip(
flush: 'post',
});
onUnmounted(() => {
onBeforeUnmount(() => {
close();
if (elRef.value) {
const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
el.removeEventListener('mouseover', onMouseover);
el.removeEventListener('mouseleave', onMouseleave);
el.removeEventListener('touchstart', onTouchstart);
el.removeEventListener('touchend', onTouchend);
el.removeEventListener('click', close);
}
});
}

View File

@@ -7,9 +7,7 @@ import { throttle } from 'throttle-debounce';
import type { Directive } from 'vue';
import type { Awaitable } from '@/types/misc.js';
interface HTMLElementWithObserver extends HTMLElement {
_observer_?: IntersectionObserver;
}
const observers = new WeakMap<HTMLElement, IntersectionObserver>();
export const appearDirective = {
mounted(src, binding) {
@@ -23,13 +21,16 @@ export const appearDirective = {
});
const observer = new IntersectionObserver(check);
observer.observe(src);
src._observer_ = observer;
observers.set(src, observer);
},
unmounted(src) {
if (src._observer_) src._observer_.disconnect();
beforeUnmount(src) {
const observer = observers.get(src);
if (observer) {
observer.disconnect();
observers.delete(src);
}
},
} as Directive<HTMLElementWithObserver, (() => Awaitable<void>) | null | undefined>;
} as Directive<HTMLElement, (() => Awaitable<void>) | null | undefined>;

View File

@@ -6,6 +6,8 @@
import type { Directive } from 'vue';
import { prefer } from '@/preferences.js';
const abortControllers = new WeakMap<HTMLElement, AbortController>();
export const clickAnimeDirective = {
mounted(el) {
if (!prefer.s.animation) return;
@@ -14,6 +16,9 @@ export const clickAnimeDirective = {
if (target == null) return;
const abortController = new AbortController();
abortControllers.set(el, abortController);
target.classList.add('_anime_bounce_standBy');
el.addEventListener('mousedown', () => {
@@ -25,16 +30,26 @@ export const clickAnimeDirective = {
target.addEventListener('mouseleave', () => {
target.classList.remove('_anime_bounce_ready');
});
});
}, { signal: abortController.signal });
el.addEventListener('click', () => {
target.classList.add('_anime_bounce');
target.classList.remove('_anime_bounce_ready');
});
}, { signal: abortController.signal });
el.addEventListener('animationend', () => {
target.classList.remove('_anime_bounce');
target.classList.add('_anime_bounce_standBy');
});
}, { signal: abortController.signal });
},
beforeUnmount(el) {
if (!prefer.s.animation) return;
const abortController = abortControllers.get(el);
if (abortController) {
abortController.abort();
abortControllers.delete(el);
}
},
} as Directive<HTMLElement>;

View File

@@ -6,14 +6,17 @@
import type { Directive } from 'vue';
import { getScrollContainer, getScrollPosition } from '@@/js/scroll.js';
interface HTMLElementWithRO extends HTMLElement {
_ro_?: ResizeObserver;
}
const states = new WeakMap<HTMLElement, {
observer: ResizeObserver;
abortController: AbortController;
}>();
export const followAppendDirective = {
mounted(src, binding) {
if (binding.value === false) return;
const abortController = new AbortController();
let isBottom = true;
const container = getScrollContainer(src)!;
@@ -22,10 +25,10 @@ export const followAppendDirective = {
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
isBottom = (pos + viewHeight > height - 32);
}, { passive: true });
}, { passive: true, signal: abortController.signal });
container.scrollTop = container.scrollHeight;
const ro = new ResizeObserver((entries, observer) => {
const ro = new ResizeObserver(() => {
if (isBottom) {
const height = container.scrollHeight;
container.scrollTop = height;
@@ -34,11 +37,18 @@ export const followAppendDirective = {
ro.observe(src);
// TODO: 新たにプロパティを作るのをやめMapを使う
src._ro_ = ro;
states.set(src, {
observer: ro,
abortController,
});
},
unmounted(src) {
if (src._ro_) src._ro_.unobserve(src);
beforeUnmount(src) {
const state = states.get(src);
if (!state) return;
state.observer.disconnect();
state.abortController.abort();
states.delete(src);
},
} as Directive<HTMLElementWithRO, boolean>;
} as Directive<HTMLElement, boolean>;

View File

@@ -7,30 +7,28 @@ import type { Directive } from 'vue';
import { makeHotkey } from '@/utility/hotkey.js';
import type { Keymap } from '@/utility/hotkey.js';
interface HTMLElementWithHotkey extends HTMLElement {
_hotkey_global?: boolean;
_keyHandler?: (ev: KeyboardEvent) => void;
}
const abortControllers = new WeakMap<HTMLElement, AbortController>();
export const hotkeyDirective = {
mounted(el, binding) {
el._hotkey_global = binding.modifiers.global === true;
const isGlobal = (binding.modifiers.global === true);
const keyHandler = makeHotkey(binding.value);
const abortController = new AbortController();
el._keyHandler = makeHotkey(binding.value);
if (el._hotkey_global) {
window.document.addEventListener('keydown', el._keyHandler, { passive: false });
if (isGlobal) {
window.document.addEventListener('keydown', keyHandler, { passive: false, signal: abortController.signal });
} else {
el.addEventListener('keydown', el._keyHandler, { passive: false });
el.addEventListener('keydown', keyHandler, { passive: false, signal: abortController.signal });
}
abortControllers.set(el, abortController);
},
unmounted(el) {
if (el._keyHandler == null) return;
if (el._hotkey_global) {
window.document.removeEventListener('keydown', el._keyHandler);
} else {
el.removeEventListener('keydown', el._keyHandler);
beforeUnmount(el) {
const abortController = abortControllers.get(el);
if (abortController) {
abortController.abort();
abortControllers.delete(el);
}
},
} as Directive<HTMLElementWithHotkey, Keymap>;
} as Directive<HTMLElement, Keymap>;

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