Compare commits
18 Commits
2026.7.0-a
...
improve-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5609abf25 | ||
|
|
cf3b206ef4 | ||
|
|
951e22242d | ||
|
|
cc356cf542 | ||
|
|
f0b3a7241d | ||
|
|
e7bc3821d1 | ||
|
|
d2647e066e | ||
|
|
b7ca70bd1e | ||
|
|
564ed21b74 | ||
|
|
5c7f547342 | ||
|
|
75e97da41e | ||
|
|
059fbaa92b | ||
|
|
76ab562810 | ||
|
|
9472e72678 | ||
|
|
9440423a73 | ||
|
|
b2867eb0d7 | ||
|
|
5590f8bad9 | ||
|
|
c15f93e48d |
@@ -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 過剰登録なし |
|
||||
|
||||
|
||||
@@ -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 側に委譲。
|
||||
|
||||
@@ -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`) の前提ではない (ポート競合の元になるため使わないこと)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 必ず最後に通る場所
|
||||
|
||||
|
||||
@@ -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`) とはポートが異なり、混同すると接続できない。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
1
.github/workflows/check-spdx-license-id.yml
vendored
@@ -44,7 +44,6 @@ jobs:
|
||||
}
|
||||
|
||||
directories=(
|
||||
"cypress/e2e"
|
||||
"packages/backend/migration"
|
||||
"packages/backend/src"
|
||||
"packages/backend/test"
|
||||
|
||||
40
.github/workflows/test-frontend.yml
vendored
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:61812',
|
||||
},
|
||||
})
|
||||
@@ -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: 投稿フォームのハッシュタグ保持フィールドのテスト
|
||||
@@ -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')
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
*/
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom"],
|
||||
"target": "esnext",
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/']);
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
32
packages/frontend/playwright.config.ts
Normal 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',
|
||||
},
|
||||
}],
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
432
packages/frontend/src/components/MkImageGallery.item.vue
Normal 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>
|
||||
135
packages/frontend/src/components/MkImageGallery.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- パスワードレスログイン -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
39
packages/frontend/src/utility/animation.ts
Normal 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);
|
||||
}
|
||||
50
packages/frontend/src/utility/double-tap.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||