1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-09 19:14:41 +02:00

Compare commits

..

18 Commits

Author SHA1 Message Date
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
138 changed files with 1685 additions and 1571 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

@@ -308,7 +308,7 @@ id: 'aidx'
# # To check enabled names, set `options.debug: true` and see
# # "Integration installed: <name>" in the Sentry logs.
# #
# # As of 2026-07-05 / @sentry/node 10.59.0, useful names for Misskey include:
# # 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
@@ -317,6 +317,17 @@ id: 'aidx'
# 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

@@ -24,6 +24,7 @@
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
### Server
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
@@ -33,6 +34,7 @@
- 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

@@ -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",
@@ -68,7 +66,6 @@
"@typescript-eslint/parser": "8.62.0",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"cross-env": "10.1.0",
"cypress": "15.18.0",
"eslint": "9.39.4",
"globals": "17.7.0",
"ncp": "2.0.0",

View File

@@ -7,8 +7,10 @@ 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[];
@@ -34,6 +36,33 @@ export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions)
};
}
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'),
@@ -44,23 +73,7 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
// 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,
}),
});
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

@@ -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

@@ -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/']);
});
});

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"
@@ -54,7 +56,6 @@
"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",
@@ -71,6 +72,7 @@
"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",
@@ -104,6 +106,7 @@
"@types/tinycolor2": "1.4.6",
"@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.39",
"astring": "1.9.0",
@@ -120,6 +123,7 @@
"msw-storybook-addon": "2.0.7",
"nodemon": "3.1.14",
"oxc-walker": "1.0.0",
"playwright": "1.61.1",
"prettier": "3.9.1",
"react": "19.2.7",
"react-dom": "19.2.7",

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,432 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
ref="rootEl"
:class="$style.root"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="onPointerup"
@touchstart="onTouchstart"
@touchmove="onTouchmove"
@wheel="onWheel"
>
<div :style="{ transform: `translate3d(${translation.x}px, ${translation.y}px, 0)` }">
<img
v-if="!originalImageLoaded"
:class="[$style.image, $style.thumbnail]"
:src="image.thumbnailUrl"
:width="size.width"
:height="size.height"
draggable="false"
>
<img
v-if="activated"
ref="imageEl"
:class="[$style.image, $style.original]"
:src="image.url"
:width="size.width"
:height="size.height"
draggable="false"
@load="originalImageLoaded = true"
>
</div>
<div v-if="activated && !originalImageLoaded" :class="$style.loading">
<MkLoading/>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { makeDoubleTapDetector } from '@/utility/double-tap.js';
import { beginAnimation, easing_easeInOutQuad } from '@/utility/animation.js';
export type Image = {
id: string;
url: string;
thumbnailUrl: string;
width: number;
height: number;
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 padding = 30;
const ANIMATION_DURATION = 200;
function calcDefaultSize(image: Image) {
const maxWidth = window.innerWidth - padding * 2;
const maxHeight = window.innerHeight - padding * 2;
let width = image.width;
let height = image.height;
if (width > maxWidth) {
height = height * (maxWidth / width);
width = maxWidth;
}
if (height > maxHeight) {
width = width * (maxHeight / height);
height = maxHeight;
}
return { width, height };
}
function calcDefaultTranslation(image: Image) {
const defaultSize = calcDefaultSize(image);
const x = (window.innerWidth - defaultSize.width) / 2;
const y = (window.innerHeight - defaultSize.height) / 2;
return { x, y };
}
const defaultSize = calcDefaultSize(props.image);
const defaultTranslation = calcDefaultTranslation(props.image);
const size = ref({ width: defaultSize.width, height: defaultSize.height });
const translation = ref({ x: defaultTranslation.x, y: defaultTranslation.y });
const isZooming = ref(false);
function zoomInTo(x: number, y: number, factor = 1.1, withAnimation = false) {
const newWidth = size.value.width * factor;
const newHeight = size.value.height * factor;
isZooming.value = true;
// Center the image on the cursor
const rect = imageEl.value?.getBoundingClientRect();
if (!rect) return;
const offsetX = x - rect.left;
const offsetY = y - rect.top;
if (withAnimation) {
beginAnimation({
from: {
width: size.value.width,
height: size.value.height,
x: translation.value.x,
y: translation.value.y,
},
to: {
width: newWidth,
height: newHeight,
x: translation.value.x - offsetX * (factor - 1),
y: translation.value.y - offsetY * (factor - 1),
},
duration: ANIMATION_DURATION,
easing: easing_easeInOutQuad,
apply: (state) => {
size.value.width = state.width;
size.value.height = state.height;
translation.value.x = state.x;
translation.value.y = state.y;
},
});
} else {
size.value.width = newWidth;
size.value.height = newHeight;
translation.value.x -= offsetX * (factor - 1);
translation.value.y -= offsetY * (factor - 1);
}
}
function resetSizeAndTranslation() {
isZooming.value = false;
beginAnimation({
from: {
width: size.value.width,
height: size.value.height,
x: translation.value.x,
y: translation.value.y,
},
to: {
width: defaultSize.width,
height: defaultSize.height,
x: defaultTranslation.x,
y: defaultTranslation.y,
},
duration: ANIMATION_DURATION,
easing: easing_easeInOutQuad,
apply: (state) => {
size.value.width = state.width;
size.value.height = state.height;
translation.value.x = state.x;
translation.value.y = state.y;
},
});
}
function onWheel(event: WheelEvent) {
event.preventDefault();
const delta = event.deltaY;
const scaleFactor = 1.1;
const scale = delta > 0 ? 1 / scaleFactor : scaleFactor;
const newWidth = size.value.width * scale;
const newHeight = size.value.height * scale;
if (newWidth < defaultSize.width || newHeight < defaultSize.height) {
size.value.width = defaultSize.width;
size.value.height = defaultSize.height;
translation.value.x = defaultTranslation.x;
translation.value.y = defaultTranslation.y;
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 (size.value.width < defaultSize.width || size.value.height < defaultSize.height) {
isZooming.value = false;
resetSizeAndTranslation();
}
}
// ズーム中、ドラッグされたら画像を移動する
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;
function onPointermove(ev: PointerEvent) {
ev.preventDefault();
ev.stopPropagation();
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) {
translation.value.x += deltaX;
translation.value.y += deltaY;
} else {
if (isVerticalSwiping) {
translation.value.y += deltaY;
verticalSwipeDelta += deltaY;
} else if (isHorizontalSwiping) {
//translation.value.x += deltaX;
horizontalSwipeDelta = ev.clientX - currentPointerStartOffset.x;
emit('horizontalSwipe', horizontalSwipeDelta);
} else {
const isVerticalVector = Math.abs(deltaY) > Math.abs(deltaX);
if (isVerticalVector) {
isVerticalSwiping = true;
} else {
isHorizontalSwiping = true;
}
}
}
pointerVec = { x: deltaX, y: deltaY }; // TODO: おそらくこの計算方法だと高リフレッシュレートで実行される環境ほど同じ動かし方でもベクトルは小さくなってしまうと思われるので良い感じにする
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 < -5); // 上の方で離された、または上に向かって強めに弾かれた
const shouldCloseByDownwardSwipe = verticalSwipeDelta > 200 || (verticalSwipeDelta > 0 && pointerVec.y > 5); // 下の方で離された、または下に向かって強めに弾かれた
if (shouldCloseByUpwardSwipe || shouldCloseByDownwardSwipe) {
emit('close');
return;
}
resetSizeAndTranslation();
} else if (isHorizontalSwiping) {
const shouldNext = horizontalSwipeDelta < -150 || (horizontalSwipeDelta < 0 && pointerVec.x < -3); // 左の方で離された、または左に向かって強めに弾かれた
const shouldPrev = horizontalSwipeDelta > 150 || (horizontalSwipeDelta > 0 && pointerVec.x > 3); // 右の方で離された、または右に向かって強めに弾かれた
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;
resetSizeAndTranslation();
} 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) {
ev.preventDefault();
ev.stopPropagation();
doubleTapDetector.onTouchmove(ev);
}
let rafHandle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
let latestInertiaTimeStamp = 0;
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.5 && Math.abs(pointerVec.y) < 0.5) return;
translation.value.x += pointerVec.x;
translation.value.y += pointerVec.y;
pointerVec.x *= 0.9 ** (timeDelta / 16.67);
pointerVec.y *= 0.9 ** (timeDelta / 16.67);
}
onMounted(() => {
rafHandle = window.requestAnimationFrame(updateInertia);
});
onBeforeUnmount(() => {
if (rafHandle != null) window.cancelAnimationFrame(rafHandle);
});
</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: 0;
left: 0;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
display: grid;
place-items: center;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,135 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }">
<div :class="[$style.bg]"></div>
<div ref="mainEl" :class="$style.main">
<div ref="itemsEl" :class="$style.items" :style="{ left: `${imagesOffset}px` }">
<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>
</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 { beginAnimation, easing_easeInOutQuad } from '@/utility/animation.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 });
const zIndex = os.claimZIndex('high');
const screenWidth = ref(window.innerWidth);
const imagesOffset = ref(currentIndex.value * -window.innerWidth);
let currentScrollLeft = imagesOffset.value;
function onHorizontalSwipe(offset: number) {
imagesOffset.value = currentScrollLeft + offset;
}
function scrollToCurrentIndex() {
currentScrollLeft = currentIndex.value * -screenWidth.value;
beginAnimation({
from: { value: imagesOffset.value },
to: { value: currentIndex.value * -screenWidth.value },
duration: 300,
easing: easing_easeInOutQuad,
apply: ({ value }) => {
imagesOffset.value = value;
},
});
}
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() {
emit('closed');
}
</script>
<style lang="scss" module>
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
}
.main {
position: absolute;
}
.items {
position: absolute;
display: flex;
width: calc(v-bind("screenWidth + 'px'") * v-bind("images.length"));
height: 100dvh;
overflow: clip;
contain: strict;
}
.item {
width: 100dvw;
height: 100dvh;
overflow: clip;
contain: strict;
flex-shrink: 0;
}
</style>

View File

@@ -55,7 +55,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) {

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}`" :disableImageLink="true" :class="$style.media" :data-id="media.id" :image="media" :raw="raw" @click="openGallery(media.id)"/>
</template>
</div>
</div>
@@ -31,9 +31,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, 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';
@@ -48,18 +45,7 @@ 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();
}
};
async function calcAspectRatio() {
if (!gallery.value) return;
@@ -96,121 +82,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 +93,22 @@ 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,
sourceElement: gallery.value?.querySelector(`.image[data-id="${media.id}"]`) as HTMLElement | undefined,
}));
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,

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

@@ -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>;

View File

@@ -8,12 +8,17 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { prefer } from '@/preferences.js';
import { popup } from '@/os.js';
const handlers = new WeakMap<HTMLElement, (ev: MouseEvent) => void>();
const abortControllers = new WeakMap<HTMLElement, AbortController>();
export const rippleDirective = {
mounted(el, binding) {
// 明示的に false であればバインドしない
if (binding.value === false) return;
if (!prefer.s.animation) return;
const abortController = new AbortController();
el.addEventListener('click', () => {
const rect = el.getBoundingClientRect();
@@ -23,6 +28,16 @@ export const rippleDirective = {
const { dispose } = popup(MkRippleEffect, { x, y }, {
end: () => dispose(),
});
});
}, { passive: true, signal: abortController.signal });
abortControllers.set(el, abortController);
},
} as Directive<HTMLElement, boolean | undefined>;
beforeUnmount(el) {
const abortController = abortControllers.get(el);
if (abortController) {
abortController.abort();
abortControllers.delete(el);
}
},
} as Directive<HTMLElement, boolean | null | undefined>;

View File

@@ -17,16 +17,15 @@ const end = isTouchUsing ? 'touchend' : 'mouseleave';
type TooltipDirectiveState = {
text: string | null | undefined;
_close: null | (() => void);
showTimer: number | null;
hideTimer: number | null;
checkTimer: number | null;
show: () => void;
close: () => void;
abortController: AbortController;
showTimer: number | null;
hideTimer: number | null;
};
interface TooltipDirectiveElement extends HTMLElement {
_tooltipDirective_?: TooltipDirectiveState;
}
const states = new WeakMap<HTMLElement, TooltipDirectiveState>();
type TooltipDirectiveModifiers = 'left' | 'right' | 'top' | 'bottom' | 'mfm' | 'noDelay';
type TooltipDirectiveArg = 'dialog';
@@ -35,44 +34,44 @@ export const tooltipDirective = {
mounted(el, binding) {
const delay = binding.modifiers.noDelay ? 0 : 100;
const self = el._tooltipDirective_ = {} as TooltipDirectiveState;
const state = {
text: binding.value,
_close: null,
abortController: new AbortController(),
showTimer: null,
hideTimer: null,
} as TooltipDirectiveState;
self.text = binding.value;
self._close = null;
self.showTimer = null;
self.hideTimer = null;
self.checkTimer = null;
self.close = () => {
if (self._close) {
if (self.checkTimer) window.clearInterval(self.checkTimer);
self._close();
self._close = null;
state.close = () => {
if (state._close) {
state._close();
state._close = null;
}
};
if (binding.arg === 'dialog') {
el.addEventListener('click', (ev) => {
if (binding.value == null) return;
const text = state.text ?? undefined;
if (text == null) return;
ev.preventDefault();
ev.stopPropagation();
alert({
type: 'info',
text: binding.value,
text,
});
return false;
});
}, { signal: state.abortController.signal });
}
self.show = () => {
state.show = () => {
if (!window.document.body.contains(el)) return;
if (self._close) return;
if (self.text == null) return;
if (state._close) return;
if (state.text == null) return;
const showing = ref(true);
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
showing,
text: self.text,
text: state.text,
asMfm: binding.modifiers.mfm,
direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top',
anchorElement: el,
@@ -80,53 +79,59 @@ export const tooltipDirective = {
closed: () => dispose(),
});
self._close = () => {
state._close = () => {
showing.value = false;
};
};
el.addEventListener('selectstart', ev => {
el.addEventListener('selectstart', (ev) => {
ev.preventDefault();
});
}, { signal: state.abortController.signal });
el.addEventListener(start, (ev) => {
if (self.showTimer) window.clearTimeout(self.showTimer);
if (self.hideTimer) window.clearTimeout(self.hideTimer);
el.addEventListener(start, () => {
if (state.showTimer) window.clearTimeout(state.showTimer);
if (state.hideTimer) window.clearTimeout(state.hideTimer);
if (delay === 0) {
self.show();
state.show();
} else {
self.showTimer = window.setTimeout(self.show, delay);
state.showTimer = window.setTimeout(state.show, delay);
}
}, { passive: true });
}, { passive: true, signal: state.abortController.signal });
el.addEventListener(end, () => {
if (self.showTimer) window.clearTimeout(self.showTimer);
if (self.hideTimer) window.clearTimeout(self.hideTimer);
if (state.showTimer) window.clearTimeout(state.showTimer);
if (state.hideTimer) window.clearTimeout(state.hideTimer);
if (delay === 0) {
self.close();
state.close();
} else {
self.hideTimer = window.setTimeout(self.close, delay);
state.hideTimer = window.setTimeout(state.close, delay);
}
}, { passive: true });
}, { passive: true, signal: state.abortController.signal });
el.addEventListener('click', () => {
if (self.showTimer) window.clearTimeout(self.showTimer);
self.close();
});
if (state.showTimer) window.clearTimeout(state.showTimer);
state.close();
}, { passive: true, signal: state.abortController.signal });
states.set(el, state);
},
updated(el, binding) {
const self = el._tooltipDirective_;
if (self == null) return;
self.text = binding.value as string;
const state = states.get(el);
if (!state) return;
state.text = binding.value;
},
unmounted(el) {
const self = el._tooltipDirective_;
if (self == null) return;
if (self.showTimer) window.clearTimeout(self.showTimer);
if (self.hideTimer) window.clearTimeout(self.hideTimer);
if (self.checkTimer) window.clearTimeout(self.checkTimer);
self.close();
beforeUnmount(el) {
const state = states.get(el);
if (!state) return;
if (state.showTimer) window.clearTimeout(state.showTimer);
if (state.hideTimer) window.clearTimeout(state.hideTimer);
state.close();
state.abortController.abort();
states.delete(el);
},
} as Directive<TooltipDirectiveElement, string | null | undefined, TooltipDirectiveModifiers, TooltipDirectiveArg>;
} as Directive<HTMLElement, string | null | undefined, TooltipDirectiveModifiers, TooltipDirectiveArg>;

View File

@@ -106,29 +106,24 @@ export class UserPreview {
}
}
interface UserPreviewDirectiveElement extends HTMLElement {
_userPreviewDirective_?: {
preview: UserPreview;
};
}
const states = new WeakMap<HTMLElement, UserPreview>();
export const userPreviewDirective = {
mounted(el, binding) {
if (binding.value == null) return;
if (isTouchUsing) return;
// TODO: 新たにプロパティを作るのをやめMapを使う
// ただメモリ的には↓の方が省メモリかもしれないので検討中
el._userPreviewDirective_ = {
preview: new UserPreview(el, binding.value),
};
// メモリ的にはWeakMapを使わずに要素にプロパティを生やしたほうが省メモリかもしれないので検討中
const preview = new UserPreview(el, binding.value);
states.set(el, preview);
},
unmounted(el, binding) {
if (binding.value == null) return;
const self = el._userPreviewDirective_;
if (self == null) return;
self.preview.detach();
const preview = states.get(el);
if (preview) {
preview.detach();
states.delete(el);
}
},
} as Directive<UserPreviewDirectiveElement, string | Misskey.entities.UserDetailed | null | undefined>;
} as Directive<HTMLElement, string | Misskey.entities.UserDetailed | null | undefined>;

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/account-data" :label="i18n.ts._settings.accountData" :keywords="['import', 'export', 'data', 'archive']" icon="ti ti-package">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/package_3d.png" color="#ff9100">
<MkFeatureBanner icon="/fluent-emoji/1f4e6.png" color="#ff9100">
<SearchText>{{ i18n.ts._settings.accountDataBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/connect" :label="i18n.ts._settings.serviceConnection" :keywords="['app', 'service', 'connect', 'webhook', 'api', 'token']" icon="ti ti-link">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/link_3d.png" color="#ff0088">
<MkFeatureBanner icon="/fluent-emoji/1f517.png" color="#ff0088">
<SearchText>{{ i18n.ts._settings.serviceConnectionBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/drive" :label="i18n.ts.drive" :keywords="['drive']" icon="ti ti-cloud">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/cloud_3d.png" color="#0059ff">
<MkFeatureBanner icon="/fluent-emoji/2601.png" color="#0059ff">
<SearchText>{{ i18n.ts._settings.driveBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/emoji-palette" :label="i18n.ts.emojiPalette" :keywords="['emoji', 'palette']" icon="ti ti-mood-happy">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/artist_palette_3d.png" color="#ff9100">
<MkFeatureBanner icon="/fluent-emoji/1f3a8.png" color="#ff9100">
<SearchText>{{ i18n.ts._settings.emojiPaletteBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/mute-block" :label="i18n.ts.muteAndBlock" icon="ti ti-ban" :keywords="['mute', 'block']">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/prohibited_3d.png" color="#ff2600">
<MkFeatureBanner icon="/fluent-emoji/1f6ab.png" color="#ff2600">
<SearchText>{{ i18n.ts._settings.muteAndBlockBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/notifications" :label="i18n.ts.notifications" :keywords="['notifications']" icon="ti ti-bell">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/bell_3d.png" color="#ffff00">
<MkFeatureBanner icon="/fluent-emoji/1f514.png" color="#ffff00">
<SearchText>{{ i18n.ts._settings.notificationsBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/plugin" :label="i18n.ts.plugins" :keywords="['plugin', 'addon', 'extension']" icon="ti ti-plug">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/electric_plug_3d.png" color="#ffbb00">
<MkFeatureBanner icon="/fluent-emoji/1f50c.png" color="#ffbb00">
<SearchText>{{ i18n.ts._settings.pluginBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/preferences" :label="i18n.ts.preferences" :keywords="['general', 'preferences']" icon="ti ti-adjustments">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/gear_3d.png" color="#00ff9d">
<MkFeatureBanner icon="/fluent-emoji/2699.png" color="#00ff9d">
<SearchText>{{ i18n.ts._settings.preferencesBanner }}</SearchText>
</MkFeatureBanner>
@@ -491,7 +491,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><SearchIcon><i class="ti ti-accessible"></i></SearchIcon></template>
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/mens_room_3d.png" color="#0011ff">
<MkFeatureBanner icon="/fluent-emoji/1f6b9.png" color="#0011ff">
<SearchText>{{ i18n.ts._settings.accessibilityBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/privacy" :label="i18n.ts.privacy" :keywords="['privacy']" icon="ti ti-lock-open">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/unlocked_3d.png" color="#aeff00">
<MkFeatureBanner icon="/fluent-emoji/1f513.png" color="#aeff00">
<SearchText>{{ i18n.ts._settings.privacyBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00">
<MkFeatureBanner icon="/fluent-emoji/1f510.png" color="#ffbf00">
<SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<SearchMarker path="/settings/sounds" :label="i18n.ts.sounds" :keywords="['sounds']" icon="ti ti-music">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/speaker_high_volume_3d.png" color="#ff006f">
<MkFeatureBanner icon="/fluent-emoji/1f50a.png" color="#ff006f">
<SearchText>{{ i18n.ts._settings.soundsBanner }}</SearchText>
</MkFeatureBanner>

View File

@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m" style="padding: 32px;">
<div>{{ i18n.tsx.clickToFinishEmailVerification({ ok: i18n.ts.gotIt }) }}</div>
<div>
<MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;">
<MkButton gradate large rounded type="submit" :disabled="submitting" data-testid="admin-ok" style="margin: 0 auto;">
{{ submitting ? i18n.ts.processing : i18n.ts.gotIt }}<MkEllipsis v-if="submitting"/>
</MkButton>
</div>

View File

@@ -53,21 +53,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><b>{{ i18n.ts._serverSetupWizard.installCompleted }}</b></div>
<div>{{ i18n.ts._serverSetupWizard.firstCreateAccount }}</div>
</div>
<MkInput v-model="setupPassword" type="password" data-cy-admin-initial-password>
<MkInput v-model="setupPassword" type="password" data-testid="admin-initial-password">
<template #label>{{ i18n.ts.initialPasswordForSetup }} <div v-tooltip:dialog="i18n.ts.initialPasswordForSetupDescription" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username>
<MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-testid="admin-username">
<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>
</MkInput>
<MkInput v-model="password" type="password" data-cy-admin-password>
<MkInput v-model="password" type="password" data-testid="admin-password">
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
<div>
<MkButton gradate large rounded :disabled="accountCreating" data-cy-admin-ok style="margin: 0 auto;" type="submit">
<MkButton gradate large rounded :disabled="accountCreating" data-testid="admin-ok" style="margin: 0 auto;" type="submit">
{{ accountCreating ? i18n.ts.processing : i18n.ts.next }}<MkEllipsis v-if="accountCreating"/>
</MkButton>
</div>
@@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="text-align: center;" class="_gaps_s">
<div><b>{{ i18n.ts._serverSetupWizard.accountCreated }}</b></div>
</div>
<MkButton gradate large rounded data-cy-next style="margin: 0 auto;" @click="step++">
<MkButton gradate large rounded data-testid="next" style="margin: 0 auto;" @click="step++">
{{ i18n.ts.next }}
</MkButton>
</div>
@@ -112,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLink target="_blank" url="https://misskey-hub.net/docs/donate/" style="margin: 0 auto;">{{ i18n.ts.learnMore }}</MkLink>
</div>
<div class="_buttonsCenter">
<MkButton gradate large rounded data-cy-next style="margin: 0 auto;" @click="finish">
<MkButton gradate large rounded data-testid="next" style="margin: 0 auto;" @click="finish">
{{ i18n.ts.start }}
</MkButton>
</div>

View File

@@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="store.r.realtimeMode.value" class="ti ti-bolt ti-fw"></i>
<i v-else class="ti ti-bolt-off ti-fw"></i>
</button>
<button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-cy-open-post-form @click="() => { os.post(); }">
<button v-tooltip.noDelay.right="i18n.ts.note" class="_button" :class="[$style.post]" data-testid="open-post-form" @click="() => { os.post(); }">
<i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span>
</button>
<button v-if="$i != null" v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu">

View File

@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
<button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em; margin-top: 16px;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
<button v-else class="_textButton" data-testid="widget-edit" :class="$style.edit" style="font-size: 0.9em; margin-top: 16px;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
</div>
</template>

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const easing_easeInOutQuad = (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
export function beginAnimation<const T extends Record<string, number>>(options: {
from: T;
to: T;
duration: number;
easing?: (t: number) => number;
apply: (state: T) => void;
onEnd?: () => void;
}) {
const easing = options.easing ?? ((t: number) => t);
const startTime = performance.now();
function animate(now: number) {
const elapsed = now - startTime;
const t = easing(Math.min(elapsed / options.duration, 1));
const state = {} as T;
for (const _key in options.from) {
const key = _key as keyof T;
state[key] = options.from[key] + (options.to[key] - options.from[key]) * t;
}
options.apply(state);
if (t < 1) {
window.requestAnimationFrame(animate);
} else {
options.onEnd?.();
}
}
window.requestAnimationFrame(animate);
}

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function makeDoubleTapDetector(onDoubletap: (event: TouchEvent) => void) {
const positionThreshold = 10; // px
const durationThreshold = 300; // ms
let lastTapTime = 0;
let lastTapPosition = { x: 0, y: 0 };
function onTouchstart(ev: TouchEvent) {
if (ev.touches.length !== 1) return;
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTapTime;
const positionDelta = Math.max(
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
);
if (tapLength < durationThreshold && tapLength > 0 && positionDelta < positionThreshold) { // ダブルタップ
onDoubletap(ev);
lastTapTime = 0;
return;
}
lastTapTime = currentTime;
lastTapPosition = {
x: ev.touches[0].clientX,
y: ev.touches[0].clientY,
};
}
function onTouchmove(ev: TouchEvent) {
const positionDelta = Math.max(
Math.abs(ev.touches[0].clientX - lastTapPosition.x),
Math.abs(ev.touches[0].clientY - lastTapPosition.y),
);
if (positionDelta > positionThreshold) {
lastTapTime = 0;
}
}
return {
onTouchstart,
onTouchmove,
};
}

View File

@@ -53,7 +53,6 @@ let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
export const makeHotkey = (keymap: Keymap, ignoreElements = IGNORE_ELEMENTS) => {
const actions = parseKeymap(keymap);
return (ev: KeyboardEvent) => {
if ('pswp' in window && window.pswp != null) return;
if (window.document.activeElement != null) {
if (ignoreElements.includes(window.document.activeElement.tagName.toLowerCase())) return;
if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;

View File

@@ -17,16 +17,6 @@ const PREFIX = 'idbfallback::';
let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (window.Cypress) {
idbAvailable = false;
console.log('Cypress detected. It will use localStorage.');
}
if (idbAvailable) {
await iset('idb-test', 'test')
.catch(err => {

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent" data-cy-mkw-activity class="mkw-activity">
<MkContainer :showHeader="widgetProps.showHeader" :naked="widgetProps.transparent" data-testid="mkw-activity" class="mkw-activity">
<template #icon><i class="ti ti-chart-line"></i></template>
<template #header>{{ i18n.ts._widgets.activity }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="toggleView()"><i class="ti ti-selector"></i></button></template>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-cy-mkw-aichan class="mkw-aichan">
<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-testid="mkw-aichan" class="mkw-aichan">
<iframe ref="live2d" :class="$style.root" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
</MkContainer>
</template>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-aiscript class="mkw-aiscript">
<MkContainer :showHeader="widgetProps.showHeader" data-testid="mkw-aiscript" class="mkw-aiscript">
<template #icon><i class="ti ti-terminal-2"></i></template>
<template #header>{{ i18n.ts._widgets.aiscript }}</template>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div data-cy-mkw-button class="mkw-button">
<div data-testid="mkw-button" class="mkw-button">
<MkButton :primary="widgetProps.colored" full @click="run">
{{ widgetProps.label }}
</MkButton>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root, { _panel: !widgetProps.transparent }]" data-cy-mkw-calendar>
<div :class="[$style.root, { _panel: !widgetProps.transparent }]" data-testid="mkw-calendar">
<div :class="[$style.calendar, { [$style.isHoliday]: isHoliday }]">
<p :class="$style.monthAndYear">
<span :class="$style.year">{{ i18n.tsx.yearX({ year }) }}</span>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-cy-mkw-clock>
<MkContainer :naked="widgetProps.transparent" :showHeader="false" data-testid="mkw-clock">
<div
:class="[$style.root, {
[$style.small]: widgetProps.size === 'small',

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div data-cy-mkw-digitalClock class="_monospace" :class="[$style.root, { _panel: !widgetProps.transparent }]" :style="{ fontSize: `${widgetProps.fontSize}em` }">
<div data-testid="mkw-digitalClock" class="_monospace" :class="[$style.root, { _panel: !widgetProps.transparent }]" :style="{ fontSize: `${widgetProps.fontSize}em` }">
<div v-if="widgetProps.showLabel" :class="$style.label">{{ tzAbbrev }}</div>
<div>
<MkDigitalClock :showMs="widgetProps.showMs" :offset="tzOffset"/>

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" data-cy-mkw-federation class="mkw-federation">
<MkContainer :showHeader="widgetProps.showHeader" data-testid="mkw-federation" class="mkw-federation">
<template #icon><i class="ti ti-whirl"></i></template>
<template #header>{{ i18n.ts._widgets.federation }}</template>

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