Compare commits
43 Commits
feature/ro
...
improve-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5dd17e7e2 | ||
|
|
a129b453b0 | ||
|
|
392fb2c40d | ||
|
|
3ecb2b4d13 | ||
|
|
f3dcca2d31 | ||
|
|
327c447b9a | ||
|
|
62afbc32fb | ||
|
|
5677646bd5 | ||
|
|
53b4d184ac | ||
|
|
1cabf88445 | ||
|
|
b47303f6d6 | ||
|
|
b48321154f | ||
|
|
f70048bd6c | ||
|
|
4b592f9c94 | ||
|
|
f5f5eab09e | ||
|
|
324aea004d | ||
|
|
dc07f0c6de | ||
|
|
2e54afd3a7 | ||
|
|
e41aedeb06 | ||
|
|
c3f8c0a871 | ||
|
|
95901e5220 | ||
|
|
d5609abf25 | ||
|
|
cf3b206ef4 | ||
|
|
951e22242d | ||
|
|
cc356cf542 | ||
|
|
f0b3a7241d | ||
|
|
e7bc3821d1 | ||
|
|
d2647e066e | ||
|
|
b7ca70bd1e | ||
|
|
564ed21b74 | ||
|
|
5c7f547342 | ||
|
|
75e97da41e | ||
|
|
059fbaa92b | ||
|
|
76ab562810 | ||
|
|
9472e72678 | ||
|
|
9440423a73 | ||
|
|
b2867eb0d7 | ||
|
|
5590f8bad9 | ||
|
|
c15f93e48d | ||
|
|
03d224acd0 | ||
|
|
c24ce9b55a | ||
|
|
7243bd34b9 | ||
|
|
d4dd02c744 |
@@ -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`) とはポートが異なり、混同すると接続できない。
|
||||
|
||||
@@ -303,8 +303,31 @@ id: 'aidx'
|
||||
|
||||
#sentryForBackend:
|
||||
# enableNodeProfiling: true
|
||||
# # Specify Sentry integration names to disable individual auto-instrumentation.
|
||||
# # The names are integration .name values, not factory function names.
|
||||
# # To check enabled names, set `options.debug: true` and see
|
||||
# # "Integration installed: <name>" in the Sentry logs.
|
||||
# #
|
||||
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
|
||||
# # Postgres ... DB queries (when pg is externalized)
|
||||
# # Redis ... ioredis commands
|
||||
# # Fastify ... inbound HTTP routes
|
||||
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
|
||||
# # NodeFetch ... fetch/undici requests
|
||||
# disabledIntegrations: ['Postgres']
|
||||
# options:
|
||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
|
||||
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
|
||||
# # To intentionally propagate distributed traces to trusted internal services,
|
||||
# # list only those internal URL patterns here.
|
||||
# # Avoid broad patterns that match remote servers unless you intentionally
|
||||
# # want to restore Sentry's legacy behavior of propagating traces to all
|
||||
# # outbound HTTP requests.
|
||||
# #tracePropagationTargets: []
|
||||
# # Internal allowlist example:
|
||||
# #tracePropagationTargets:
|
||||
# # - 'internal-service.example'
|
||||
|
||||
#sentryForFrontend:
|
||||
# vueIntegration:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
|
||||
|
||||
### General
|
||||
- Feat: 公開ロールとロールバッジをユーザー側で個別に非表示にできるように
|
||||
- Feat: 公開ロールとロールバッジを、上記の設定にかかわらず強制的に表示させることができるように
|
||||
- 既存のロールについてはすべて強制表示が有効となります。必要に応じて設定を変更してください。
|
||||
- 今後新規作成するロールのデフォルト値は強制表示なし(ユーザーが非表示にできる)となります。
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
@@ -28,13 +24,17 @@
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
- Fix: 自分へのメンションに対する色分けで、判定が大文字/小文字を区別していた問題を修正
|
||||
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
|
||||
|
||||
### Server
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
|
||||
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
|
||||
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
|
||||
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
|
||||
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
|
||||
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
|
||||
|
||||
## 2026.6.0
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -2083,8 +2083,6 @@ _role:
|
||||
isConditionalRole: "これはコンディショナルロールです。"
|
||||
isPublic: "公開ロール"
|
||||
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
|
||||
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
|
||||
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
|
||||
options: "オプション"
|
||||
policies: "ポリシー"
|
||||
baseRole: "ベースロール"
|
||||
@@ -3247,12 +3245,6 @@ _gridComponent:
|
||||
patternNotMatch: "この値は{pattern}のパターンに一致しません"
|
||||
notUnique: "この値は一意である必要があります"
|
||||
|
||||
_roleDisplay:
|
||||
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
|
||||
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
|
||||
displayToggle: "ロール/ロールバッジを表示する"
|
||||
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"
|
||||
|
||||
_roleSelectDialog:
|
||||
notSelected: "選択されていません"
|
||||
|
||||
|
||||
26
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.7.0-alpha.1",
|
||||
"version": "2026.7.0-alpha.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"workspaces": [
|
||||
"packages/misskey-js",
|
||||
"packages/i18n",
|
||||
@@ -41,10 +41,8 @@
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"check-dts": "node scripts/check-dts.mjs",
|
||||
"lint": "pnpm --no-bail -r lint && pnpm check-dts",
|
||||
"cy:open": "pnpm cypress open --config-file=cypress.config.ts",
|
||||
"cy:run": "pnpm cypress run",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
|
||||
"e2e-dev-container": "ncp ./.config/playwright-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 \"pnpm --filter frontend test:e2e\"",
|
||||
"backend-unit-test": "cd packages/backend && pnpm test",
|
||||
"backend-unit-test-and-coverage": "cd packages/backend && pnpm test-and-coverage",
|
||||
"test": "pnpm -r test",
|
||||
@@ -57,23 +55,21 @@
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "9.0.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"tar": "7.5.16"
|
||||
"js-yaml": "5.2.0",
|
||||
"tar": "7.5.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.4",
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@types/node": "26.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260426.1",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.17.0",
|
||||
"eslint": "9.39.4",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "11.8.0",
|
||||
"pnpm": "11.9.0",
|
||||
"start-server-and-test": "3.0.11",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class RoleDisplayVisibility1783059479536 {
|
||||
name = 'RoleDisplayVisibility1783059479536';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "hiddenRoleIds" character varying(32) array NOT NULL DEFAULT '{}'`);
|
||||
await queryRunner.query(`ALTER TABLE "role" ADD "isPublicDisplayRequired" boolean NOT NULL DEFAULT false`);
|
||||
|
||||
// 既存のロールについてはすべて強制表示とする(新規作成分についてはfalse)
|
||||
await queryRunner.query(`UPDATE "role" SET "isPublicDisplayRequired" = true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isPublicDisplayRequired"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hiddenRoleIds"`);
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,8 @@
|
||||
"utf-8-validate": "6.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1073.0",
|
||||
"@aws-sdk/lib-storage": "3.1073.0",
|
||||
"@aws-sdk/client-s3": "3.1075.0",
|
||||
"@aws-sdk/lib-storage": "3.1075.0",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/http-proxy": "11.5.0",
|
||||
@@ -63,23 +63,22 @@
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@misskey-dev/sharp-read-bmp": "1.3.1",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@napi-rs/canvas": "1.0.0",
|
||||
"@napi-rs/canvas": "1.0.1",
|
||||
"@nestjs/common": "11.1.27",
|
||||
"@nestjs/core": "11.1.27",
|
||||
"@nestjs/testing": "11.1.27",
|
||||
"@oxc-project/runtime": "0.137.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.59.0",
|
||||
"@sentry/profiling-node": "10.59.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@sinonjs/fake-timers": "15.4.0",
|
||||
"@smithy/node-http-handler": "4.8.1",
|
||||
"@sentry/node": "10.62.0",
|
||||
"@sentry/profiling-node": "10.62.0",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@smithy/node-http-handler": "4.8.2",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.20.0",
|
||||
"archiver": "8.0.0",
|
||||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bullmq": "5.79.0",
|
||||
"bullmq": "5.79.2",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"chalk": "5.6.2",
|
||||
"chalk-template": "1.1.2",
|
||||
@@ -88,12 +87,12 @@
|
||||
"content-disposition": "2.0.1",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-email-validator": "0.1.27",
|
||||
"fastify": "5.8.5",
|
||||
"fastify": "5.9.0",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "5.2.1",
|
||||
"file-type": "22.0.1",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"got": "15.0.5",
|
||||
"got": "15.0.7",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "1.1.3",
|
||||
"i18n": "workspace:*",
|
||||
@@ -110,10 +109,10 @@
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"ms": "3.0.0-canary.202508261828",
|
||||
"nanoid": "5.1.14",
|
||||
"nanoid": "5.1.16",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "7.1.0",
|
||||
"node-html-parser": "8.0.3",
|
||||
"nodemailer": "9.0.1",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.5.1",
|
||||
@@ -135,7 +134,7 @@
|
||||
"slacc": "0.1.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.31.7",
|
||||
"systeminformation": "5.31.11",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.7",
|
||||
"tsc-alias": "1.8.17",
|
||||
@@ -150,16 +149,16 @@
|
||||
"@kitajs/ts-html-plugin": "4.1.4",
|
||||
"@nestjs/platform-express": "11.1.27",
|
||||
"@rollup/plugin-esm-shim": "0.1.8",
|
||||
"@sentry/vue": "10.59.0",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@sinonjs/fake-timers": "15.4.0",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "8.0.0",
|
||||
"@types/fluent-ffmpeg": "2.1.28",
|
||||
"@types/http-link-header": "1.0.7",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonld": "1.5.15",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/node": "26.0.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
@@ -170,14 +169,13 @@
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/simple-oauth2": "5.0.8",
|
||||
"@types/sinonjs__fake-timers": "15.0.1",
|
||||
"@types/supertest": "7.2.0",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cbor2": "2.3.0",
|
||||
@@ -185,11 +183,10 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "9.6.1",
|
||||
"fkill": "10.0.3",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.2.0",
|
||||
"pid-port": "2.1.1",
|
||||
"rolldown": "1.1.2",
|
||||
"rolldown": "1.1.3",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.2.2",
|
||||
"vite": "8.1.0",
|
||||
"vitest": "4.1.9",
|
||||
"vitest-mock-extended": "4.0.0"
|
||||
|
||||
@@ -75,6 +75,8 @@ export default defineConfig((args) => {
|
||||
're2',
|
||||
'ipaddr.js',
|
||||
'file-type',
|
||||
// バンドルするとSentryの自動計装が正しく行われなくなるため外しておく
|
||||
'pg',
|
||||
];
|
||||
|
||||
const define: Record<string, string> = {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import fs from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { load as loadYaml } from 'js-yaml';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
@@ -34,7 +34,7 @@ function yamlToJson(ymlPath) {
|
||||
console.log(`${ymlPath} → ${OUTPUT_PATH}`);
|
||||
|
||||
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
|
||||
const jsonContent = yaml.load(yamlContent);
|
||||
const jsonContent = loadYaml(yamlContent);
|
||||
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
|
||||
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
type SentryBackendConfig = {
|
||||
options: Partial<Sentry.NodeOptions>;
|
||||
enableNodeProfiling: boolean;
|
||||
disabledIntegrations?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 設定ファイルの型
|
||||
*/
|
||||
@@ -64,7 +70,7 @@ type Source = {
|
||||
index: string;
|
||||
scope?: 'local' | 'global' | string[];
|
||||
};
|
||||
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
|
||||
sentryForBackend?: SentryBackendConfig;
|
||||
sentryForFrontend?: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
@@ -201,7 +207,7 @@ export type Config = {
|
||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||
redisForReactions: RedisOptions & RedisOptionsSource;
|
||||
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
|
||||
sentryForBackend: SentryBackendConfig | undefined;
|
||||
sentryForFrontend: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { URLSearchParams } from 'node:url';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import juice from 'juice';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { validate as validateEmail } from 'deep-email-validator';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
@@ -197,6 +195,7 @@ export class EmailService {
|
||||
} else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) {
|
||||
validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey);
|
||||
} else {
|
||||
const { validate: validateEmail } = await import('deep-email-validator');
|
||||
validated = await validateEmail({
|
||||
email: emailAddress,
|
||||
validateRegex: true,
|
||||
|
||||
@@ -676,7 +676,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
target: values.target,
|
||||
condFormula: values.condFormula,
|
||||
isPublic: values.isPublic,
|
||||
isPublicDisplayRequired: values.isPublicDisplayRequired ?? false,
|
||||
isAdministrator: values.isAdministrator,
|
||||
isModerator: values.isModerator,
|
||||
isExplorable: values.isExplorable,
|
||||
|
||||
@@ -55,7 +55,6 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
makeNotesHiddenBefore: null,
|
||||
chatScope: 'mutual',
|
||||
emojis: [],
|
||||
hiddenRoleIds: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
inbox: null,
|
||||
|
||||
@@ -64,7 +64,6 @@ export class RoleEntityService {
|
||||
target: role.target,
|
||||
condFormula: role.condFormula,
|
||||
isPublic: role.isPublic,
|
||||
isPublicDisplayRequired: role.isPublicDisplayRequired,
|
||||
isAdministrator: role.isAdministrator,
|
||||
isModerator: role.isModerator,
|
||||
isExplorable: role.isExplorable,
|
||||
@@ -84,32 +83,5 @@ export class RoleEntityService {
|
||||
) {
|
||||
return Promise.all(roles.map(x => this.pack(x, me)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLite(
|
||||
src: MiRole['id'] | MiRole,
|
||||
): Promise<Packed<'RoleLite'>> {
|
||||
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
iconUrl: role.iconUrl,
|
||||
description: role.description,
|
||||
isModerator: role.isModerator,
|
||||
isAdministrator: role.isAdministrator,
|
||||
asBadge: role.asBadge,
|
||||
isPublicDisplayRequired: role.isPublicDisplayRequired,
|
||||
isExplorable: role.isExplorable,
|
||||
displayOrder: role.displayOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public packLiteMany(
|
||||
roles: (MiRole | MiRole['id'])[],
|
||||
) {
|
||||
return Promise.all(roles.map(x => this.packLite(x)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { Promiseable } from '@/misc/prelude/await-all.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
||||
import type { MiRole } from '@/models/Role.js';
|
||||
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import {
|
||||
birthdaySchema,
|
||||
@@ -52,7 +51,6 @@ import { ChatService } from '@/core/ChatService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { PageEntityService } from './PageEntityService.js';
|
||||
import type { RoleEntityService } from './RoleEntityService.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
@@ -90,7 +88,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
private apPersonService: ApPersonService;
|
||||
private noteEntityService: NoteEntityService;
|
||||
private pageEntityService: PageEntityService;
|
||||
private roleEntityService: RoleEntityService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private announcementService: AnnouncementService;
|
||||
private roleService: RoleService;
|
||||
@@ -147,7 +144,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
this.apPersonService = this.moduleRef.get('ApPersonService');
|
||||
this.noteEntityService = this.moduleRef.get('NoteEntityService');
|
||||
this.pageEntityService = this.moduleRef.get('PageEntityService');
|
||||
this.roleEntityService = this.moduleRef.get('RoleEntityService');
|
||||
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
||||
this.announcementService = this.moduleRef.get('AnnouncementService');
|
||||
this.roleService = this.moduleRef.get('RoleService');
|
||||
@@ -406,30 +402,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
return `${this.config.url}/users/${userId}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private prepareRoles<T extends Pick<MiRole, 'id' | 'isPublic' | 'displayOrder' | 'isPublicDisplayRequired'>>(roles: T[], user: MiUser, iAmModerator: boolean, hideByUserPreference = true): T[] {
|
||||
const hiddenRoleIds = new Set(user.hiddenRoleIds);
|
||||
return roles.filter(role => (role.isPublic || iAmModerator) && (role.isPublicDisplayRequired || iAmModerator || !hideByUserPreference || !hiddenRoleIds.has(role.id))).sort((a, b) => b.displayOrder - a.displayOrder);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private sanitizeHiddenRoleIds(roles: Pick<MiRole, 'id' | 'isPublic' | 'isPublicDisplayRequired'>[], user: MiUser): MiRole['id'][] {
|
||||
const hideableRoleIds = new Set(roles
|
||||
.filter(role => role.isPublic && !role.isPublicDisplayRequired)
|
||||
.map(role => role.id));
|
||||
const sanitizedRoleIds: MiRole['id'][] = [];
|
||||
const seenRoleIds = new Set<MiRole['id']>();
|
||||
|
||||
for (const roleId of user.hiddenRoleIds) {
|
||||
if (!hideableRoleIds.has(roleId) || seenRoleIds.has(roleId)) continue;
|
||||
|
||||
sanitizedRoleIds.push(roleId);
|
||||
seenRoleIds.add(roleId);
|
||||
}
|
||||
|
||||
return sanitizedRoleIds;
|
||||
}
|
||||
|
||||
public async pack<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>(
|
||||
src: MiUser['id'] | MiUser,
|
||||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
@@ -453,7 +425,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
const meId = me ? me.id : null;
|
||||
const isMe = meId === user.id;
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
const userRoles = isDetailed ? this.roleService.getUserRoles(user.id) : null;
|
||||
|
||||
const profile = isDetailed
|
||||
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
||||
@@ -543,9 +514,10 @@ export class UserEntityService implements OnModuleInit {
|
||||
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
|
||||
onlineStatus: this.getOnlineStatus(user),
|
||||
// パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得
|
||||
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => this.prepareRoles(rs, user, iAmModerator)
|
||||
badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || user.host == null) ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
|
||||
.filter((r) => r.isPublic || iAmModerator)
|
||||
.sort((a, b) => b.displayOrder - a.displayOrder)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
iconUrl: r.iconUrl,
|
||||
displayOrder: r.displayOrder,
|
||||
@@ -588,7 +560,16 @@ export class UserEntityService implements OnModuleInit {
|
||||
followingVisibility: profile!.followingVisibility,
|
||||
chatScope: user.chatScope,
|
||||
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
|
||||
roles: userRoles!.then(roles => this.roleEntityService.packLiteMany(this.prepareRoles(roles, user, iAmModerator, !isMe))),
|
||||
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
iconUrl: role.iconUrl,
|
||||
description: role.description,
|
||||
isModerator: role.isModerator,
|
||||
isAdministrator: role.isAdministrator,
|
||||
displayOrder: role.displayOrder,
|
||||
}))),
|
||||
memo: memo,
|
||||
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
|
||||
} : {}),
|
||||
@@ -617,7 +598,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
preventAiLearning: profile!.preventAiLearning,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
hiddenRoleIds: userRoles!.then(roles => this.sanitizeHiddenRoleIds(roles, user)),
|
||||
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: false, // 後方互換性のため
|
||||
|
||||
@@ -6,6 +6,63 @@
|
||||
import type { Config } from '@/config.js';
|
||||
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
|
||||
type SentryIntegrationsOption = NonNullable<import('@sentry/node').NodeOptions['integrations']>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SentryIntegrationFactory = Extract<SentryIntegrationsOption, (integrations: any[]) => any[]>;
|
||||
type SentryIntegration = Parameters<SentryIntegrationFactory>[0][number];
|
||||
type SentryNodeOptions = import('@sentry/node').NodeOptions;
|
||||
|
||||
type BuildSentryIntegrationsOptions = {
|
||||
disabledIntegrations?: string[];
|
||||
enableNodeProfiling: boolean;
|
||||
nodeProfilingIntegration?: () => SentryIntegration;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
export function buildSentryIntegrations(options: BuildSentryIntegrationsOptions): SentryIntegrationFactory {
|
||||
return (defaults) => {
|
||||
const disabledIntegrations = new Set(options.disabledIntegrations ?? []);
|
||||
const defaultIntegrationNames = new Set(defaults.map((integration) => integration.name));
|
||||
const unknownIntegrations = [...disabledIntegrations].filter((name) => !defaultIntegrationNames.has(name));
|
||||
|
||||
if (unknownIntegrations.length > 0) {
|
||||
(options.warn ?? console.warn)(`Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: ${unknownIntegrations.join(', ')}`);
|
||||
}
|
||||
|
||||
return [
|
||||
...defaults.filter((integration) => !disabledIntegrations.has(integration.name)),
|
||||
...(options.enableNodeProfiling && options.nodeProfilingIntegration != null ? [options.nodeProfilingIntegration()] : []),
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSentryNodeOptions(
|
||||
config: NonNullable<Config['sentryForBackend']>,
|
||||
nodeProfilingIntegration?: () => SentryIntegration,
|
||||
): SentryNodeOptions {
|
||||
return {
|
||||
// Do not send Sentry trace headers to remote ActivityPub/Webhook/etc. hosts by default.
|
||||
// Admins can opt in for trusted internal services via sentryForBackend.options.
|
||||
tracePropagationTargets: [],
|
||||
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 1.0,
|
||||
|
||||
maxBreadcrumbs: 0,
|
||||
|
||||
...config.options,
|
||||
|
||||
integrations: buildSentryIntegrations({
|
||||
disabledIntegrations: config.disabledIntegrations,
|
||||
enableNodeProfiling: config.enableNodeProfiling,
|
||||
nodeProfilingIntegration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
private constructor(
|
||||
private readonly Sentry: typeof import('@sentry/node'),
|
||||
@@ -16,21 +73,7 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
...(config.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
|
||||
],
|
||||
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 1.0,
|
||||
|
||||
maxBreadcrumbs: 0,
|
||||
|
||||
...config.options,
|
||||
});
|
||||
Sentry.init(buildSentryNodeOptions(config, nodeProfilingIntegration));
|
||||
|
||||
return new SentryTelemetryAdapter(Sentry);
|
||||
}
|
||||
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -227,11 +227,6 @@ export class MiRole {
|
||||
})
|
||||
public isPublic: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isPublicDisplayRequired: boolean;
|
||||
|
||||
// trueの場合ユーザー名の横にバッジとして表示
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import type { MiRole } from './Role.js';
|
||||
|
||||
@Entity('user')
|
||||
@Index(['usernameLower', 'host'], { unique: true })
|
||||
@@ -230,11 +229,6 @@ export class MiUser {
|
||||
})
|
||||
public emojis: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 32, array: true, default: '{}',
|
||||
})
|
||||
public hiddenRoleIds: MiRole['id'][];
|
||||
|
||||
// チャットを許可する相手
|
||||
// everyone: 誰からでも
|
||||
// followers: フォロワーのみ
|
||||
|
||||
@@ -369,21 +369,6 @@ export const packedRoleLiteSchema = {
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
asBadge: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isPublicDisplayRequired: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isExplorable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
displayOrder: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
@@ -427,11 +412,6 @@ export const packedRoleSchema = {
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isPublicDisplayRequired: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
example: false,
|
||||
},
|
||||
isExplorable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
||||
@@ -176,11 +176,6 @@ export const packedUserLiteSchema = {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'id',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
@@ -516,15 +511,6 @@ export const packedMeDetailedOnlySchema = {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
hiddenRoleIds: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'id',
|
||||
},
|
||||
},
|
||||
twoFactorBackupCodesStock: {
|
||||
type: 'string',
|
||||
enum: ['full', 'partial', 'none'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,6 @@ export const paramDef = {
|
||||
target: { type: 'string', enum: ['manual', 'conditional'] },
|
||||
condFormula: { type: 'object' },
|
||||
isPublic: { type: 'boolean' },
|
||||
isPublicDisplayRequired: { type: 'boolean', default: false },
|
||||
isModerator: { type: 'boolean' },
|
||||
isAdministrator: { type: 'boolean' },
|
||||
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
|
||||
|
||||
@@ -37,7 +37,6 @@ export const paramDef = {
|
||||
target: { type: 'string', enum: ['manual', 'conditional'] },
|
||||
condFormula: { type: 'object' },
|
||||
isPublic: { type: 'boolean' },
|
||||
isPublicDisplayRequired: { type: 'boolean' },
|
||||
isModerator: { type: 'boolean' },
|
||||
isAdministrator: { type: 'boolean' },
|
||||
isExplorable: { type: 'boolean' },
|
||||
@@ -76,7 +75,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
target: ps.target,
|
||||
condFormula: ps.condFormula,
|
||||
isPublic: ps.isPublic,
|
||||
isPublicDisplayRequired: ps.isPublicDisplayRequired,
|
||||
isModerator: ps.isModerator,
|
||||
isAdministrator: ps.isAdministrator,
|
||||
isExplorable: ps.isExplorable,
|
||||
|
||||
@@ -191,12 +191,6 @@ export const paramDef = {
|
||||
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
|
||||
chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
|
||||
hiddenRoleIds: {
|
||||
type: 'array',
|
||||
maxItems: 256,
|
||||
uniqueItems: true,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
mutedWords: muteWords,
|
||||
hardMutedWords: muteWords,
|
||||
@@ -299,20 +293,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
|
||||
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
|
||||
if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
|
||||
if (ps.hiddenRoleIds !== undefined) {
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
const allowedRoleIds = new Set(roles.filter(role => role.isPublic && !role.isPublicDisplayRequired).map(role => role.id));
|
||||
const hiddenRoleIds: string[] = [];
|
||||
const seenRoleIds = new Set<string>();
|
||||
|
||||
for (const roleId of ps.hiddenRoleIds) {
|
||||
if (seenRoleIds.has(roleId) || !allowedRoleIds.has(roleId)) continue;
|
||||
seenRoleIds.add(roleId);
|
||||
hiddenRoleIds.push(roleId);
|
||||
}
|
||||
|
||||
updates.hiddenRoleIds = hiddenRoleIds;
|
||||
}
|
||||
|
||||
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
|
||||
const count = (arr: (string[] | string)[]) => {
|
||||
|
||||
@@ -128,7 +128,6 @@ describe('ユーザー', () => {
|
||||
preventAiLearning: user.preventAiLearning,
|
||||
isExplorable: user.isExplorable,
|
||||
isDeleted: user.isDeleted,
|
||||
hiddenRoleIds: user.hiddenRoleIds ?? [],
|
||||
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
|
||||
@@ -661,16 +660,12 @@ describe('ユーザー', () => {
|
||||
description: rolePublic.description,
|
||||
isModerator: rolePublic.isModerator,
|
||||
isAdministrator: rolePublic.isAdministrator,
|
||||
asBadge: rolePublic.asBadge,
|
||||
isPublicDisplayRequired: rolePublic.isPublicDisplayRequired,
|
||||
isExplorable: rolePublic.isExplorable,
|
||||
displayOrder: rolePublic.displayOrder,
|
||||
}]);
|
||||
});
|
||||
test('を取得することができ、バッヂロールがセットされていること', async () => {
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
|
||||
assert.deepStrictEqual(response.badgeRoles, [{
|
||||
id: roleBadge.id,
|
||||
name: roleBadge.name,
|
||||
iconUrl: roleBadge.iconUrl,
|
||||
displayOrder: roleBadge.displayOrder,
|
||||
@@ -683,42 +678,9 @@ describe('ユーザー', () => {
|
||||
description: roleBadge.description,
|
||||
isModerator: roleBadge.isModerator,
|
||||
isAdministrator: roleBadge.isAdministrator,
|
||||
asBadge: roleBadge.asBadge,
|
||||
isPublicDisplayRequired: roleBadge.isPublicDisplayRequired,
|
||||
isExplorable: roleBadge.isExplorable,
|
||||
displayOrder: roleBadge.displayOrder,
|
||||
}]);
|
||||
});
|
||||
test('i/updateでhiddenRoleIdsが保存時に表示可能な割り当て済みロールだけへsanitizeされること', async () => {
|
||||
const user = await signup({ username: 'userHiddenRoles' });
|
||||
const visibleRole = await role(root, { isPublic: true, name: 'Hideable Role' });
|
||||
const secondVisibleRole = await role(root, { isPublic: true, name: 'Second Hideable Role' });
|
||||
const privateRole = await role(root, { isPublic: false, name: 'Private Role' });
|
||||
const forcedRole = await role(root, { isPublic: true, isPublicDisplayRequired: true, name: 'Forced Role' });
|
||||
const unassignedRole = await role(root, { isPublic: true, name: 'Unassigned Role' });
|
||||
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: visibleRole.id }, root);
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: secondVisibleRole.id }, root);
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: privateRole.id }, root);
|
||||
await api('admin/roles/assign', { userId: user.id, roleId: forcedRole.id }, root);
|
||||
|
||||
const response = await successfulApiCall({
|
||||
endpoint: 'i/update',
|
||||
parameters: {
|
||||
hiddenRoleIds: [
|
||||
secondVisibleRole.id,
|
||||
'unknownroleid',
|
||||
unassignedRole.id,
|
||||
privateRole.id,
|
||||
forcedRole.id,
|
||||
visibleRole.id,
|
||||
],
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(response.hiddenRoleIds, [secondVisibleRole.id, visibleRole.id]);
|
||||
});
|
||||
test('をID指定のリスト形式で取得することができる(空)', async () => {
|
||||
const parameters = { userIds: [] };
|
||||
const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice });
|
||||
|
||||
60
packages/backend/test/unit/SentryTelemetryAdapter.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { buildSentryIntegrations } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
|
||||
|
||||
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
|
||||
|
||||
function testIntegration(name: string): TestIntegration {
|
||||
return { name };
|
||||
}
|
||||
|
||||
describe('SentryTelemetryAdapter', () => {
|
||||
test('removes disabled integrations from Sentry defaults', () => {
|
||||
const integrations = buildSentryIntegrations({
|
||||
disabledIntegrations: ['Postgres'],
|
||||
enableNodeProfiling: false,
|
||||
});
|
||||
|
||||
const result = integrations([
|
||||
testIntegration('Http'),
|
||||
testIntegration('Postgres'),
|
||||
testIntegration('Redis'),
|
||||
]);
|
||||
|
||||
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Redis']);
|
||||
});
|
||||
|
||||
test('keeps profiling integration when enabled', () => {
|
||||
const integrations = buildSentryIntegrations({
|
||||
disabledIntegrations: [],
|
||||
enableNodeProfiling: true,
|
||||
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
|
||||
});
|
||||
|
||||
const result = integrations([
|
||||
testIntegration('Http'),
|
||||
]);
|
||||
|
||||
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
|
||||
});
|
||||
|
||||
test('warns about unknown disabled integration names without removing defaults', () => {
|
||||
const warn = vi.fn();
|
||||
const integrations = buildSentryIntegrations({
|
||||
disabledIntegrations: ['Unknown'],
|
||||
enableNodeProfiling: false,
|
||||
warn,
|
||||
});
|
||||
|
||||
const result = integrations([
|
||||
testIntegration('Http'),
|
||||
]);
|
||||
|
||||
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http']);
|
||||
expect(warn).toHaveBeenCalledWith('Unknown Sentry integration configured in sentryForBackend.disabledIntegrations: Unknown');
|
||||
});
|
||||
});
|
||||
@@ -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/']);
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,7 @@ import { genAidx } from '@/misc/id/aidx.js';
|
||||
import {
|
||||
BlockingsRepository,
|
||||
FollowingsRepository, FollowRequestsRepository,
|
||||
MiRole,
|
||||
MiUserProfile, MutingsRepository, RenoteMutingsRepository,
|
||||
RoleAssignmentsRepository,
|
||||
RolesRepository,
|
||||
UserMemoRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
@@ -30,7 +27,6 @@ import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
@@ -71,13 +67,6 @@ describe('UserEntityService', () => {
|
||||
let blockingRepository: BlockingsRepository;
|
||||
let mutingRepository: MutingsRepository;
|
||||
let renoteMutingsRepository: RenoteMutingsRepository;
|
||||
let rolesRepository: RolesRepository;
|
||||
let roleAssignmentsRepository: RoleAssignmentsRepository;
|
||||
let roleService: RoleService;
|
||||
type RoleServiceCacheController = {
|
||||
rolesCache: { delete(): void };
|
||||
roleAssignmentByUserIdCache: { delete(userId: MiUser['id']): void };
|
||||
};
|
||||
|
||||
async function createUser(userData: Partial<MiUser> = {}, profileData: Partial<MiUserProfile> = {}) {
|
||||
const un = secureRndstr(16);
|
||||
@@ -98,32 +87,6 @@ describe('UserEntityService', () => {
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createRole(roleData: Partial<MiRole> = {}) {
|
||||
const role = await rolesRepository
|
||||
.insert({
|
||||
id: genAidx(Date.now()),
|
||||
updatedAt: new Date(),
|
||||
lastUsedAt: new Date(),
|
||||
name: '',
|
||||
description: '',
|
||||
...roleData,
|
||||
})
|
||||
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
(roleService as unknown as RoleServiceCacheController).rolesCache.delete();
|
||||
return role;
|
||||
}
|
||||
|
||||
async function assignRole(user: MiUser, role: MiRole) {
|
||||
await roleAssignmentsRepository.insert({
|
||||
id: genAidx(Date.now()),
|
||||
userId: user.id,
|
||||
roleId: role.id,
|
||||
});
|
||||
|
||||
(roleService as unknown as RoleServiceCacheController).roleAssignmentByUserIdCache.delete(user.id);
|
||||
}
|
||||
|
||||
async function memo(writer: MiUser, target: MiUser, memo: string) {
|
||||
await userMemosRepository.insert({
|
||||
id: genAidx(Date.now()),
|
||||
@@ -183,7 +146,6 @@ describe('UserEntityService', () => {
|
||||
ApPersonService,
|
||||
NoteEntityService,
|
||||
PageEntityService,
|
||||
RoleEntityService,
|
||||
CustomEmojiService,
|
||||
AnnouncementService,
|
||||
RoleService,
|
||||
@@ -234,9 +196,6 @@ describe('UserEntityService', () => {
|
||||
blockingRepository = app.get<BlockingsRepository>(DI.blockingsRepository);
|
||||
mutingRepository = app.get<MutingsRepository>(DI.mutingsRepository);
|
||||
renoteMutingsRepository = app.get<RenoteMutingsRepository>(DI.renoteMutingsRepository);
|
||||
rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
|
||||
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
|
||||
roleService = app.get<RoleService>(RoleService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -290,60 +249,6 @@ describe('UserEntityService', () => {
|
||||
expect(actual.achievements).toEqual(achievements);
|
||||
});
|
||||
|
||||
test('MeDetailed hiddenRoleIds are sanitized to assigned public non-forced roles in stored order', async() => {
|
||||
const me = await createUser();
|
||||
const visibleRole = await createRole({ name: 'visible', isPublic: true });
|
||||
const secondVisibleRole = await createRole({ name: 'visible2', isPublic: true });
|
||||
const privateRole = await createRole({ name: 'private', isPublic: false });
|
||||
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true });
|
||||
const unassignedRole = await createRole({ name: 'unassigned', isPublic: true });
|
||||
|
||||
await assignRole(me, visibleRole);
|
||||
await assignRole(me, secondVisibleRole);
|
||||
await assignRole(me, privateRole);
|
||||
await assignRole(me, forcedRole);
|
||||
await usersRepository.update(me.id, {
|
||||
hiddenRoleIds: [
|
||||
secondVisibleRole.id,
|
||||
'unknownroleid',
|
||||
unassignedRole.id,
|
||||
privateRole.id,
|
||||
forcedRole.id,
|
||||
visibleRole.id,
|
||||
secondVisibleRole.id,
|
||||
],
|
||||
});
|
||||
const updatedMe = await usersRepository.findOneByOrFail({ id: me.id });
|
||||
|
||||
const actual = await service.pack(updatedMe, updatedMe, { schema: 'MeDetailed' });
|
||||
|
||||
expect(actual.hiddenRoleIds).toEqual([secondVisibleRole.id, visibleRole.id]);
|
||||
});
|
||||
|
||||
test('UserDetailed filters hidden display roles for normal viewers, preserves forced roles, and bypasses for moderators', async() => {
|
||||
const [viewer, moderator, target] = await Promise.all([createUser(), createUser(), createUser()]);
|
||||
const hiddenRole = await createRole({ name: 'hidden', isPublic: true, asBadge: true, displayOrder: 30 });
|
||||
const visibleRole = await createRole({ name: 'visible', isPublic: true, asBadge: true, displayOrder: 20 });
|
||||
const forcedRole = await createRole({ name: 'forced', isPublic: true, isPublicDisplayRequired: true, asBadge: true, displayOrder: 10 });
|
||||
const moderatorRole = await createRole({ name: 'moderator', isModerator: true });
|
||||
|
||||
await assignRole(target, hiddenRole);
|
||||
await assignRole(target, visibleRole);
|
||||
await assignRole(target, forcedRole);
|
||||
await assignRole(moderator, moderatorRole);
|
||||
await usersRepository.update(target.id, { hiddenRoleIds: [hiddenRole.id, forcedRole.id] });
|
||||
const updatedTarget = await usersRepository.findOneByOrFail({ id: target.id });
|
||||
|
||||
const normalView = await service.pack(updatedTarget, viewer, { schema: 'UserDetailed' });
|
||||
const moderatorView = await service.pack(updatedTarget, moderator, { schema: 'UserDetailed' });
|
||||
|
||||
expect(normalView.roles.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
|
||||
expect(normalView.badgeRoles?.map(role => role.id)).toEqual([visibleRole.id, forcedRole.id]);
|
||||
expect(normalView.badgeRoles?.every(role => typeof role.id === 'string')).toBe(true);
|
||||
expect(moderatorView.roles.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
|
||||
expect(moderatorView.badgeRoles?.map(role => role.id)).toEqual([hiddenRole.id, visibleRole.id, forcedRole.id]);
|
||||
});
|
||||
|
||||
test('alsoKnownAs as string does not throw', async () => {
|
||||
const me = await createUser();
|
||||
const who = await createUser();
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.9",
|
||||
"@types/node": "26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@types/node": "26.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"rollup": "4.62.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"magic-string": "0.30.21",
|
||||
"oxc-walker": "1.0.0",
|
||||
"rolldown": "1.1.2",
|
||||
"rolldown": "1.1.3",
|
||||
"vite": "8.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/pluginutils": "5.4.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"buraha": "0.0.1",
|
||||
"frontend-shared": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
@@ -21,42 +18,34 @@
|
||||
"mfm-js": "0.26.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.62.2",
|
||||
"shiki": "4.2.0",
|
||||
"shiki": "4.3.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"uuid": "14.0.0",
|
||||
"vue": "3.5.38"
|
||||
"uuid": "14.0.1",
|
||||
"vue": "3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/pluginutils": "5.4.0",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.9",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/node": "26.0.1",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vue/runtime-core": "3.5.38",
|
||||
"acorn": "8.17.0",
|
||||
"cross-env": "10.1.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/runtime-core": "3.5.39",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.6",
|
||||
"intersection-observer": "0.12.2",
|
||||
"lightningcss": "1.32.0",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.14.6",
|
||||
"prettier": "3.8.4",
|
||||
"sass-embedded": "1.100.0",
|
||||
"start-server-and-test": "3.0.11",
|
||||
"tsx": "4.22.4",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.3.5",
|
||||
"vue-eslint-parser": "10.4.1",
|
||||
"vue-tsc": "3.3.5"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'path';
|
||||
import pluginVue from '@vitejs/plugin-vue';
|
||||
import { defineConfig, type UserConfig } from 'vite';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { load as loadYaml } from 'js-yaml';
|
||||
import { promises as fsp } from 'fs';
|
||||
|
||||
import locales from 'i18n';
|
||||
@@ -11,7 +11,7 @@ import pluginJson5 from './lib/vite-plugin-json5.js';
|
||||
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n';
|
||||
import { Features } from 'lightningcss';
|
||||
|
||||
const url = process.env.NODE_ENV === 'development' ? (yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null;
|
||||
const url = process.env.NODE_ENV === 'development' ? (loadYaml(await fsp.readFile('../../.config/default.yml', 'utf-8')) as any).url : null;
|
||||
const host = url ? (new URL(url)).hostname : undefined;
|
||||
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.0.0",
|
||||
"@types/node": "26.0.1",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"vue-eslint-parser": "10.4.1"
|
||||
},
|
||||
@@ -23,8 +23,8 @@
|
||||
"i18n": "workspace:*",
|
||||
"json5": "2.2.3",
|
||||
"misskey-js": "workspace:*",
|
||||
"shiki": "4.2.0",
|
||||
"shiki": "4.3.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"vue": "3.5.38"
|
||||
"vue": "3.5.39"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,6 @@ export function role(params: {
|
||||
isPublic?: boolean,
|
||||
isExplorable?: boolean,
|
||||
asBadge?: boolean,
|
||||
isPublicDisplayRequired?: boolean,
|
||||
canEditMembersByModerator?: boolean,
|
||||
usersCount?: number,
|
||||
}, seed?: string): entities.Role {
|
||||
@@ -381,7 +380,6 @@ export function role(params: {
|
||||
isPublic: params.isPublic ?? true,
|
||||
isExplorable: params.isExplorable ?? true,
|
||||
asBadge: params.asBadge ?? true,
|
||||
isPublicDisplayRequired: params.isPublicDisplayRequired ?? false,
|
||||
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
|
||||
usersCount: params.usersCount ?? 10,
|
||||
preserveAssignmentOnMoveAccount: false,
|
||||
|
||||
|
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"
|
||||
@@ -20,7 +22,7 @@
|
||||
"@mcaptcha/core-glue": "0.1.0-alpha-5",
|
||||
"@misskey-dev/browser-image-resizer": "2024.1.0",
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@sentry/vue": "10.59.0",
|
||||
"@sentry/vue": "10.62.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@syuilo/aiscript": "1.2.1",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
@@ -35,12 +37,10 @@
|
||||
"chartjs-chart-matrix": "3.0.4",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"chromatic": "17.5.0",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
"eventemitter3": "5.0.4",
|
||||
"execa": "9.6.1",
|
||||
"exifreader": "4.41.0",
|
||||
"frontend-shared": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
@@ -56,23 +56,23 @@
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"sanitize-html": "2.17.5",
|
||||
"shiki": "4.2.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"shiki": "4.3.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.184.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vue": "3.5.38",
|
||||
"vue": "3.5.39",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/pluginutils": "5.4.0",
|
||||
"@storybook/addon-essentials": "8.6.18",
|
||||
@@ -97,20 +97,23 @@
|
||||
"@types/insert-text-at-cursor": "0.3.2",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "26.0.0",
|
||||
"@types/node": "26.0.1",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/textarea-caret": "3.0.4",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||
"@typescript-eslint/parser": "8.62.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vue/compiler-core": "3.5.38",
|
||||
"@vue/compiler-core": "3.5.39",
|
||||
"astring": "1.9.0",
|
||||
"chromatic": "17.7.2",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"execa": "9.6.1",
|
||||
"happy-dom": "20.10.6",
|
||||
"intersection-observer": "0.12.2",
|
||||
"lightningcss": "1.32.0",
|
||||
@@ -120,13 +123,13 @@
|
||||
"msw-storybook-addon": "2.0.7",
|
||||
"nodemon": "3.1.14",
|
||||
"oxc-walker": "1.0.0",
|
||||
"prettier": "3.8.4",
|
||||
"playwright": "1.61.1",
|
||||
"prettier": "3.9.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"rolldown": "1.1.2",
|
||||
"rolldown": "1.1.3",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"sass-embedded": "1.100.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"storybook": "10.4.6",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.22.4",
|
||||
|
||||
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">
|
||||
|
||||
487
packages/frontend/src/components/MkImageGallery.item.vue
Normal file
@@ -0,0 +1,487 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="$style.root"
|
||||
@pointerdown="onPointerdown"
|
||||
@pointermove.passive="onPointermove"
|
||||
@pointerup="onPointerup"
|
||||
@touchstart="onTouchstart"
|
||||
@touchmove.passive="onTouchmove"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<div
|
||||
:class="[$style.transformer, { [$style.transition]: enableTransition }]"
|
||||
:style="{ translate: `${transform.x}px ${transform.y}px`, scale: transform.scale }"
|
||||
@transitionend.self="enableTransition = false"
|
||||
@transitioncancel.self="enableTransition = false"
|
||||
>
|
||||
<img
|
||||
v-if="!originalImageLoaded || !thumbnailImageLoaded"
|
||||
:class="[$style.image, $style.thumbnail]"
|
||||
:src="image.thumbnailUrl"
|
||||
:width="image.width"
|
||||
:height="image.height"
|
||||
:style="{ width: `${imageRenderingSize.width}px`, height: `${imageRenderingSize.height}px` }"
|
||||
draggable="false"
|
||||
@load="thumbnailImageLoaded = true"
|
||||
>
|
||||
<img
|
||||
v-if="activated"
|
||||
ref="imageEl"
|
||||
:class="[$style.image, $style.original]"
|
||||
:src="image.url"
|
||||
:width="image.width"
|
||||
:height="image.height"
|
||||
:style="{ width: `${imageRenderingSize.width}px`, height: `${imageRenderingSize.height}px` }"
|
||||
draggable="false"
|
||||
@load="originalImageLoaded = true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="activated && !originalImageLoaded" :class="$style.loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
|
||||
<div :class="$style.footerText">
|
||||
{{ image.filename }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { makeDoubleTapDetector } from '@/utility/double-tap.js';
|
||||
import { calculateSourceTransform } from '@/components/MkImageGallery.utils.js';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
|
||||
export type Image = {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
filename?: string;
|
||||
sourceElement?: HTMLElement;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: Image;
|
||||
activated: boolean;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'close'): void;
|
||||
(ev: 'horizontalSwipe', offset: number): void;
|
||||
(ev: 'next'): void;
|
||||
(ev: 'prev'): void;
|
||||
(ev: 'cancelHorizontalSwipe'): void;
|
||||
}>();
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const imageEl = useTemplateRef('imageEl');
|
||||
|
||||
const originalImageLoaded = ref(false);
|
||||
const thumbnailImageLoaded = ref(false);
|
||||
const enableTransition = ref(false);
|
||||
const infoShowing = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
rootEl.value.offsetHeight; // reflow
|
||||
infoShowing.value = true;
|
||||
});
|
||||
|
||||
const padding = deviceKind === 'smartphone' ? 0 : 30;
|
||||
const ANIMATION_DURATION = 200;
|
||||
const headerAreaSize = 0;
|
||||
const footerAreaSize = 30;
|
||||
|
||||
// maxからはみ出す場合は縮小、maxに満たない場合は拡大する(contain)
|
||||
function calcImageRenderingSize(image: Image) {
|
||||
const maxWidth = window.innerWidth - padding * 2;
|
||||
const maxHeight = window.innerHeight - headerAreaSize - footerAreaSize - padding * 2;
|
||||
|
||||
const widthRatio = maxWidth / image.width;
|
||||
const heightRatio = maxHeight / image.height;
|
||||
const ratio = widthRatio < heightRatio ? widthRatio : heightRatio;
|
||||
|
||||
const width = image.width * ratio;
|
||||
const height = image.height * ratio;
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const imageRenderingSize = calcImageRenderingSize(props.image);
|
||||
const imageRenderingRect = {
|
||||
left: (window.innerWidth - imageRenderingSize.width) / 2,
|
||||
top: headerAreaSize + (window.innerHeight - headerAreaSize - footerAreaSize - imageRenderingSize.height) / 2,
|
||||
width: imageRenderingSize.width,
|
||||
height: imageRenderingSize.height,
|
||||
};
|
||||
const transform = ref({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
// 元のimg要素の位置・サイズ(とobject-fitの設定値)を取得して、そこからneutralの位置にアニメーションするためのscaleとtranslationを計算する
|
||||
function getScaleAndTranslationForSourceElement(): { x: number; y: number; scale: number } {
|
||||
const sourceElement = props.image.sourceElement;
|
||||
if (sourceElement == null) return { x: 0, y: 0, scale: 1 };
|
||||
|
||||
return calculateSourceTransform({
|
||||
fit: window.getComputedStyle(sourceElement).objectFit,
|
||||
imageRenderingRect,
|
||||
sourceRect: sourceElement.getBoundingClientRect(),
|
||||
});
|
||||
}
|
||||
|
||||
if (props.image.sourceElement != null && props.activated) {
|
||||
const sourceTransform = getScaleAndTranslationForSourceElement();
|
||||
transform.value.scale = sourceTransform.scale;
|
||||
transform.value.x = sourceTransform.x;
|
||||
transform.value.y = sourceTransform.y;
|
||||
}
|
||||
|
||||
const isZooming = ref(false);
|
||||
|
||||
function zoomInTo(x: number, y: number, factor = 1.1, withAnimation = false) {
|
||||
const newScale = transform.value.scale * factor;
|
||||
isZooming.value = true;
|
||||
|
||||
const rect = rootEl.value.getBoundingClientRect();
|
||||
const offsetX = x - rect.left;
|
||||
const offsetY = y - rect.top;
|
||||
|
||||
const newTranslationX = offsetX - (offsetX - transform.value.x) * factor;
|
||||
const newTranslationY = offsetY - (offsetY - transform.value.y) * factor;
|
||||
|
||||
if (withAnimation) {
|
||||
enableTransition.value = true;
|
||||
}
|
||||
|
||||
transform.value.x = newTranslationX;
|
||||
transform.value.y = newTranslationY;
|
||||
transform.value.scale = newScale;
|
||||
}
|
||||
|
||||
function resetToNeutral() {
|
||||
isZooming.value = false;
|
||||
|
||||
enableTransition.value = true;
|
||||
rootEl.value.offsetHeight; // reflow
|
||||
transform.value.scale = 1;
|
||||
transform.value.x = 0;
|
||||
transform.value.y = 0;
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const delta = event.deltaY;
|
||||
|
||||
const scaleFactor = 1.1;
|
||||
const scale = delta > 0 ? 1 / scaleFactor : scaleFactor;
|
||||
|
||||
const newScale = transform.value.scale * scale;
|
||||
|
||||
if (newScale < 1) {
|
||||
transform.value.scale = 1;
|
||||
transform.value.x = 0;
|
||||
transform.value.y = 0;
|
||||
isZooming.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
zoomInTo(event.clientX, event.clientY, scale);
|
||||
}
|
||||
|
||||
function onZoomGesture(ev: { delta: number; centerX: number; centerY: number }) {
|
||||
zoomInTo(ev.centerX, ev.centerY, 1 + ev.delta / 200);
|
||||
}
|
||||
|
||||
function onZoomGestureEnd() {
|
||||
if (transform.value.scale < 1) {
|
||||
isZooming.value = false;
|
||||
resetToNeutral();
|
||||
}
|
||||
}
|
||||
|
||||
// ズーム中、ドラッグされたら画像を移動する
|
||||
let isDragging = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let currentPointerId: number | null = null;
|
||||
let currentPointerStartOffset = { x: 0, y: 0 };
|
||||
let isVerticalSwiping = false;
|
||||
let isHorizontalSwiping = false;
|
||||
let verticalSwipeDelta = 0;
|
||||
let horizontalSwipeDelta = 0;
|
||||
|
||||
const pointerEventCache = new Map<number, PointerEvent>();
|
||||
let pointerVec = { x: 0, y: 0 };
|
||||
|
||||
function onPointerdown(ev: PointerEvent) {
|
||||
pointerEventCache.set(ev.pointerId, ev);
|
||||
rootEl.value.setPointerCapture(ev.pointerId);
|
||||
|
||||
isDragging = true;
|
||||
lastX = ev.clientX;
|
||||
lastY = ev.clientY;
|
||||
pointerVec = { x: 0, y: 0 };
|
||||
if (currentPointerId == null) {
|
||||
currentPointerId = ev.pointerId;
|
||||
currentPointerStartOffset = {
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let prevTwoTouchPointsDistance = 0;
|
||||
let lastMoveTimeStamp = 0;
|
||||
|
||||
function onPointermove(ev: PointerEvent) {
|
||||
const currentTime = performance.now();
|
||||
const dt = currentTime - lastMoveTimeStamp;
|
||||
lastMoveTimeStamp = currentTime;
|
||||
|
||||
if (pointerEventCache.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerEventCache.set(ev.pointerId, ev);
|
||||
|
||||
if (pointerEventCache.size > 1) { // 2本指での操作
|
||||
pointerVec = { x: 0, y: 0 };
|
||||
currentPointerId = null;
|
||||
isVerticalSwiping = false;
|
||||
isHorizontalSwiping = false;
|
||||
const a = Array.from(pointerEventCache.values())[0];
|
||||
const b = Array.from(pointerEventCache.values())[1];
|
||||
const distance = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||
if (prevTwoTouchPointsDistance > 0) {
|
||||
const delta = distance - prevTwoTouchPointsDistance;
|
||||
onZoomGesture({ delta, centerX: (a.clientX + b.clientX) / 2, centerY: (a.clientY + b.clientY) / 2 });
|
||||
}
|
||||
prevTwoTouchPointsDistance = distance;
|
||||
return;
|
||||
}
|
||||
|
||||
prevTwoTouchPointsDistance = 0;
|
||||
|
||||
if (currentPointerId === ev.pointerId) {
|
||||
const deltaX = ev.clientX - lastX;
|
||||
const deltaY = ev.clientY - lastY;
|
||||
|
||||
if (isZooming.value) {
|
||||
transform.value.x += deltaX;
|
||||
transform.value.y += deltaY;
|
||||
} else {
|
||||
if (isVerticalSwiping) {
|
||||
transform.value.y += deltaY;
|
||||
verticalSwipeDelta += deltaY;
|
||||
} else if (isHorizontalSwiping) {
|
||||
horizontalSwipeDelta = ev.clientX - currentPointerStartOffset.x;
|
||||
emit('horizontalSwipe', horizontalSwipeDelta);
|
||||
} else {
|
||||
const isVerticalVector = Math.abs(deltaY) > Math.abs(deltaX);
|
||||
if (isVerticalVector) {
|
||||
isVerticalSwiping = true;
|
||||
} else {
|
||||
isHorizontalSwiping = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dt > 0) {
|
||||
pointerVec = { x: deltaX / dt, y: deltaY / dt };
|
||||
}
|
||||
|
||||
lastX = ev.clientX;
|
||||
lastY = ev.clientY;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPointerup(ev: PointerEvent) {
|
||||
pointerEventCache.delete(ev.pointerId);
|
||||
rootEl.value.releasePointerCapture(ev.pointerId);
|
||||
prevTwoTouchPointsDistance = 0;
|
||||
isDragging = false;
|
||||
if (currentPointerId === ev.pointerId) {
|
||||
currentPointerId = null;
|
||||
|
||||
if (isVerticalSwiping) {
|
||||
const shouldCloseByUpwardSwipe = verticalSwipeDelta < -200 || (verticalSwipeDelta < 0 && pointerVec.y < -3); // 上の方で離された、または上に向かって強めに弾かれた
|
||||
const shouldCloseByDownwardSwipe = verticalSwipeDelta > 200 || (verticalSwipeDelta > 0 && pointerVec.y > 3); // 下の方で離された、または下に向かって強めに弾かれた
|
||||
if (shouldCloseByUpwardSwipe || shouldCloseByDownwardSwipe) {
|
||||
emit('close');
|
||||
|
||||
infoShowing.value = false;
|
||||
|
||||
const sourceTransform = getScaleAndTranslationForSourceElement();
|
||||
|
||||
enableTransition.value = true;
|
||||
rootEl.value.offsetHeight; // reflow
|
||||
transform.value.x = sourceTransform.x;
|
||||
transform.value.y = sourceTransform.y;
|
||||
transform.value.scale = sourceTransform.scale;
|
||||
return;
|
||||
}
|
||||
|
||||
resetToNeutral();
|
||||
} else if (isHorizontalSwiping) {
|
||||
const shouldNext = horizontalSwipeDelta < -150 || (horizontalSwipeDelta < 0 && pointerVec.x < -1); // 左の方で離された、または左に向かって強めに弾かれた
|
||||
const shouldPrev = horizontalSwipeDelta > 150 || (horizontalSwipeDelta > 0 && pointerVec.x > 1); // 右の方で離された、または右に向かって強めに弾かれた
|
||||
if (shouldNext) {
|
||||
emit('next');
|
||||
} else if (shouldPrev) {
|
||||
emit('prev');
|
||||
} else {
|
||||
emit('cancelHorizontalSwipe');
|
||||
}
|
||||
}
|
||||
}
|
||||
isVerticalSwiping = false;
|
||||
isHorizontalSwiping = false;
|
||||
|
||||
onZoomGestureEnd();
|
||||
}
|
||||
|
||||
const doubleTapDetector = makeDoubleTapDetector((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
pointerVec = { x: 0, y: 0 };
|
||||
|
||||
if (isZooming.value) {
|
||||
isZooming.value = false;
|
||||
resetToNeutral();
|
||||
} else {
|
||||
isZooming.value = true;
|
||||
zoomInTo(ev.touches[0].clientX, ev.touches[0].clientY, 2, true);
|
||||
}
|
||||
});
|
||||
|
||||
function onTouchstart(ev: TouchEvent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
doubleTapDetector.onTouchstart(ev);
|
||||
}
|
||||
|
||||
function onTouchmove(ev: TouchEvent) {
|
||||
doubleTapDetector.onTouchmove(ev);
|
||||
}
|
||||
|
||||
let rafHandle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
|
||||
let latestInertiaTimeStamp = 0;
|
||||
|
||||
//#region inertia
|
||||
const inertiaFactor = 0.9;
|
||||
|
||||
function updateInertia(timeStamp: number) {
|
||||
rafHandle = window.requestAnimationFrame(updateInertia);
|
||||
const timeDelta = timeStamp - latestInertiaTimeStamp;
|
||||
latestInertiaTimeStamp = timeStamp;
|
||||
|
||||
if (isDragging) return;
|
||||
if (!isZooming.value) return;
|
||||
if (Math.abs(pointerVec.x) < 0.01 && Math.abs(pointerVec.y) < 0.01) return;
|
||||
transform.value.x += pointerVec.x * timeDelta;
|
||||
transform.value.y += pointerVec.y * timeDelta;
|
||||
pointerVec.x *= inertiaFactor ** (timeDelta / 16.67);
|
||||
pointerVec.y *= inertiaFactor ** (timeDelta / 16.67);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
rafHandle = window.requestAnimationFrame(updateInertia);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafHandle != null) window.cancelAnimationFrame(rafHandle);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
watch(thumbnailImageLoaded, () => {
|
||||
if (props.image.sourceElement != null && props.activated) {
|
||||
props.image.sourceElement.style.visibility = 'hidden';
|
||||
enableTransition.value = true;
|
||||
rootEl.value.offsetHeight; // reflow
|
||||
transform.value.x = 0;
|
||||
transform.value.y = 0;
|
||||
transform.value.scale = 1;
|
||||
}
|
||||
}, { once: true });
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: v-bind("headerAreaSize + 'px'");
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: v-bind("footerAreaSize + 'px'");
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.transformer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition: translate 200ms ease, scale 200ms ease;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: var(--MI_THEME-panel);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
font-size: 85%;
|
||||
color: var(--MI_THEME-fg);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease, bottom 200ms ease;
|
||||
}
|
||||
.footer.infoShowing {
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
.footerText {
|
||||
}
|
||||
</style>
|
||||
39
packages/frontend/src/components/MkImageGallery.utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Rect = Size & {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export function calculateSourceTransform({
|
||||
fit,
|
||||
imageRenderingRect,
|
||||
sourceRect,
|
||||
}: {
|
||||
fit: string;
|
||||
imageRenderingRect: Rect;
|
||||
sourceRect: Rect;
|
||||
}): { x: number; y: number; scale: number } {
|
||||
const scale = fit === 'cover'
|
||||
? Math.max(sourceRect.width / imageRenderingRect.width, sourceRect.height / imageRenderingRect.height)
|
||||
: Math.min(sourceRect.width / imageRenderingRect.width, sourceRect.height / imageRenderingRect.height);
|
||||
|
||||
const sourceImageWidth = imageRenderingRect.width * scale;
|
||||
const sourceImageHeight = imageRenderingRect.height * scale;
|
||||
const sourceImageLeft = sourceRect.left + (sourceRect.width - sourceImageWidth) / 2;
|
||||
const sourceImageTop = sourceRect.top + (sourceRect.height - sourceImageHeight) / 2;
|
||||
|
||||
return {
|
||||
x: sourceImageLeft - imageRenderingRect.left * scale,
|
||||
y: sourceImageTop - imageRenderingRect.top * scale,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
204
packages/frontend/src/components/MkImageGallery.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<!-- durationは子Itemコンポーネントがフェードイン/アウトするdurationと合わせる -->
|
||||
<Transition
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_root_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_root_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_root_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_root_leaveTo : ''"
|
||||
:duration="{ enter: prefer.s.animation ? openAnimDuration : 0, leave: prefer.s.animation ? closeAnimDuration : 0 }"
|
||||
appear
|
||||
@afterLeave="onAfterLeave"
|
||||
>
|
||||
<!-- v-ifを使うとfalseになったとき(transitionが行われている間)子コンポーネントの更新が停止するのか子コンポーネントがアニメーションされなくなる -->
|
||||
<div v-show="showing" ref="rootEl" :class="$style.root" :style="{ zIndex }">
|
||||
<div :class="[$style.bg]" class="_modalBg"></div>
|
||||
<div ref="mainEl" :class="$style.main">
|
||||
<div
|
||||
ref="itemsEl"
|
||||
:class="[$style.items, { [$style.itemsTransition]: enableSlideTransition }]"
|
||||
:style="{ translate: `${imagesOffset}px 0` }"
|
||||
@transitionend.self="onSlideTransitionFinished"
|
||||
@transitioncancel.self="onSlideTransitionFinished"
|
||||
>
|
||||
<div v-for="(image, i) in images" :key="image.url" ref="itemEl" :class="$style.item">
|
||||
<XItem
|
||||
:image="image"
|
||||
:activated="activatedIndexes.has(i)"
|
||||
@close="onItemClose"
|
||||
@horizontalSwipe="onHorizontalSwipe"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@cancelHorizontalSwipe="onCancelHorizontalSwipe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import XItem from './MkImageGallery.item.vue';
|
||||
import type { Image } from './MkImageGallery.item.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultIndex?: number;
|
||||
images: Image[];
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const activatedIndexes = ref(new Set<number>());
|
||||
const currentIndex = ref(props.defaultIndex ?? 0);
|
||||
watch(currentIndex, (newIndex) => {
|
||||
activatedIndexes.value.add(newIndex);
|
||||
}, { immediate: true });
|
||||
watch(currentIndex, (newIndex) => {
|
||||
for (let i = 0; i < props.images.length; i++) {
|
||||
props.images[i].sourceElement.style.visibility = i === newIndex ? 'hidden' : '';
|
||||
}
|
||||
}, { immediate: false });
|
||||
|
||||
const openAnimDuration = 200;
|
||||
const closeAnimDuration = 200;
|
||||
const slideAnimDuration = 300;
|
||||
const zIndex = os.claimZIndex('high');
|
||||
const showing = ref(true);
|
||||
const screenWidth = ref(window.innerWidth);
|
||||
const imagesOffset = ref(currentIndex.value * -window.innerWidth);
|
||||
const enableSlideTransition = ref(false);
|
||||
let currentScrollLeft = imagesOffset.value;
|
||||
|
||||
function onHorizontalSwipe(offset: number) {
|
||||
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
|
||||
imagesOffset.value = currentScrollLeft + (offset / 3);
|
||||
} else if (currentIndex.value === props.images.length - 1 && offset < 0) { // これ以上進めない
|
||||
imagesOffset.value = currentScrollLeft + (offset / 3);
|
||||
} else {
|
||||
imagesOffset.value = currentScrollLeft + offset;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCurrentIndex() {
|
||||
const targetOffset = currentIndex.value * -screenWidth.value;
|
||||
currentScrollLeft = targetOffset;
|
||||
|
||||
if (!prefer.s.animation || imagesOffset.value === targetOffset) {
|
||||
enableSlideTransition.value = false;
|
||||
imagesOffset.value = targetOffset;
|
||||
return;
|
||||
}
|
||||
|
||||
enableSlideTransition.value = true;
|
||||
imagesOffset.value = targetOffset;
|
||||
}
|
||||
|
||||
function onSlideTransitionFinished(ev: TransitionEvent) {
|
||||
if (ev.propertyName !== 'translate') return;
|
||||
enableSlideTransition.value = false;
|
||||
}
|
||||
|
||||
function onCancelHorizontalSwipe() {
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
if (currentIndex.value < props.images.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onPrev() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
scrollToCurrentIndex();
|
||||
}
|
||||
|
||||
function onItemClose() {
|
||||
showing.value = false;
|
||||
}
|
||||
|
||||
function onAfterLeave() {
|
||||
for (const image of props.images) {
|
||||
if (image.sourceElement != null) {
|
||||
image.sourceElement.style.visibility = '';
|
||||
}
|
||||
}
|
||||
emit('closed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_root_enterActive {
|
||||
> .bg {
|
||||
transition: opacity v-bind("openAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
|
||||
}
|
||||
}
|
||||
.transition_root_leaveActive {
|
||||
> .bg {
|
||||
transition: opacity v-bind("closeAnimDuration + 'ms'"); // 子Itemコンポーネントがフェードイン/アウトするdurationと合わせる
|
||||
}
|
||||
}
|
||||
.transition_root_enterFrom {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.transition_root_leaveTo {
|
||||
> .bg {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bg {
|
||||
}
|
||||
|
||||
.main {
|
||||
position: absolute;
|
||||
|
||||
}
|
||||
|
||||
.items {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: calc(v-bind("screenWidth + 'px'") * v-bind("images.length"));
|
||||
height: 100dvh;
|
||||
overflow: clip;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.itemsTransition {
|
||||
pointer-events: none;
|
||||
transition: translate v-bind("slideAnimDuration + 'ms'") cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: clip;
|
||||
contain: strict;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:src="src ?? undefined"
|
||||
:title="title ?? undefined"
|
||||
:alt="alt ?? undefined"
|
||||
:data-marker="marker ?? undefined"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@@ -55,7 +56,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha
|
||||
// テスト環境で Web Worker インスタンスは作成できない
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const isTest = (import.meta.env.MODE === 'test' || window.Cypress != null);
|
||||
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
|
||||
|
||||
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
|
||||
if (isTest) {
|
||||
@@ -87,8 +88,8 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { render } from 'buraha';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -110,6 +111,7 @@ const props = withDefaults(defineProps<{
|
||||
cover?: boolean;
|
||||
forceBlurhash?: boolean;
|
||||
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
|
||||
marker?: string;
|
||||
}>(), {
|
||||
transition: null,
|
||||
src: null,
|
||||
|
||||
@@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:height="image.properties.height"
|
||||
:style="hide ? 'filter: brightness(0.7);' : null"
|
||||
:class="$style.image"
|
||||
:marker="marker"
|
||||
/>
|
||||
<div
|
||||
v-else-if="prefer.s.dataSaver.media || hide"
|
||||
@@ -42,6 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:alt="image.comment || image.name"
|
||||
:title="image.comment || image.name"
|
||||
:class="$style.image"
|
||||
:data-marker="marker"
|
||||
/>
|
||||
</component>
|
||||
<template v-if="hide">
|
||||
@@ -85,6 +87,7 @@ const props = withDefaults(defineProps<{
|
||||
cover?: boolean;
|
||||
disableImageLink?: boolean;
|
||||
controls?: boolean;
|
||||
marker?: string;
|
||||
}>(), {
|
||||
cover: false,
|
||||
disableImageLink: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
|
||||
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
|
||||
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :marker="`${markerId}:${media.id}`" :disableImageLink="true" :class="$style.media" :image="media" :raw="raw" @click="openGallery(media.id)"/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,11 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import 'photoswipe/style.css';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
import XBanner from '@/components/MkMediaBanner.vue';
|
||||
import XImage from '@/components/MkMediaImage.vue';
|
||||
@@ -41,6 +38,7 @@ import XVideo from '@/components/MkMediaVideo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { focusParent } from '@/utility/focus.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
@@ -48,18 +46,8 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const gallery = useTemplateRef('gallery');
|
||||
const pswpZIndex = os.claimZIndex('middle');
|
||||
window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
|
||||
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||
let lightbox: PhotoSwipeLightbox | null = null;
|
||||
|
||||
let activeEl: HTMLElement | null = null;
|
||||
|
||||
const popstateHandler = (): void => {
|
||||
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
|
||||
lightbox.pswp.close();
|
||||
}
|
||||
};
|
||||
const markerId = genId();
|
||||
|
||||
async function calcAspectRatio() {
|
||||
if (!gallery.value) return;
|
||||
@@ -96,121 +84,9 @@ onMounted(() => {
|
||||
calcAspectRatio();
|
||||
|
||||
if (gallery.value == null) return; // TSを黙らすため
|
||||
|
||||
lightbox = new PhotoSwipeLightbox({
|
||||
dataSource: props.mediaList
|
||||
.filter(media => {
|
||||
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
|
||||
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
|
||||
})
|
||||
.map(media => {
|
||||
const item = {
|
||||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
alt: media.comment || media.name,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
comment: media.comment || media.name,
|
||||
};
|
||||
if (media.properties.orientation != null && media.properties.orientation >= 5) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
gallery: gallery.value,
|
||||
mainClass: 'pswp',
|
||||
children: '.image',
|
||||
thumbSelector: '.image',
|
||||
loop: false,
|
||||
padding: window.innerWidth > 500 ? {
|
||||
top: 32,
|
||||
bottom: 90,
|
||||
left: 32,
|
||||
right: 32,
|
||||
} : {
|
||||
top: 0,
|
||||
bottom: 78,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
imageClickAction: 'close',
|
||||
tapAction: 'close',
|
||||
bgOpacity: 1,
|
||||
showAnimationDuration: 100,
|
||||
hideAnimationDuration: 100,
|
||||
returnFocus: false,
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
lightbox.addFilter('itemData', (itemData) => {
|
||||
// element is children
|
||||
const { element } = itemData;
|
||||
|
||||
const id = element?.dataset.id;
|
||||
const file = props.mediaList.find(media => media.id === id);
|
||||
if (!file) return itemData;
|
||||
|
||||
itemData.src = file.url;
|
||||
itemData.w = Number(file.properties.width);
|
||||
itemData.h = Number(file.properties.height);
|
||||
if (file.properties.orientation != null && file.properties.orientation >= 5) {
|
||||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl ?? undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemData.alt = file.comment || file.name;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
itemData.comment = file.comment || file.name;
|
||||
itemData.thumbCropped = true;
|
||||
|
||||
return itemData;
|
||||
});
|
||||
|
||||
lightbox.on('uiRegister', () => {
|
||||
lightbox?.pswp?.ui?.registerElement({
|
||||
name: 'altText',
|
||||
className: 'pswp__alt-text-container',
|
||||
appendTo: 'wrapper',
|
||||
onInit: (el, pswp) => {
|
||||
const textBox = window.document.createElement('p');
|
||||
textBox.className = 'pswp__alt-text _acrylic';
|
||||
el.appendChild(textBox);
|
||||
|
||||
pswp.on('change', () => {
|
||||
textBox.textContent = pswp.currSlide?.data.comment;
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.on('afterInit', () => {
|
||||
activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
|
||||
focusParent(activeEl, true, true);
|
||||
lightbox?.pswp?.element?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
window.history.pushState(null, '', '#pswp');
|
||||
});
|
||||
|
||||
lightbox.on('destroy', () => {
|
||||
focusParent(activeEl, true, false);
|
||||
activeEl = null;
|
||||
if (window.location.hash === '#pswp') {
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
lightbox.init();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', popstateHandler);
|
||||
lightbox?.destroy();
|
||||
lightbox = null;
|
||||
activeEl = null;
|
||||
});
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
@@ -219,11 +95,23 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
if (props.mediaList.filter(media => previewable(media)).length > 0) {
|
||||
lightbox?.loadAndOpen(0);
|
||||
}
|
||||
};
|
||||
async function openGallery(id: string) {
|
||||
const images = props.mediaList.filter(media => previewable(media)).map(media => ({
|
||||
id: media.id,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.properties.width ?? 0,
|
||||
height: media.properties.height ?? 0,
|
||||
filename: media.name,
|
||||
sourceElement: markRaw(gallery.value?.querySelector(`[data-marker="${markerId}:${media.id}"]`)),
|
||||
}));
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
|
||||
defaultIndex: images.findIndex(image => image.id === id),
|
||||
images: images,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openGallery,
|
||||
@@ -316,6 +204,7 @@ defineExpose({
|
||||
.media {
|
||||
overflow: hidden; // clipにするとバグる
|
||||
border-radius: 8px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
@@ -331,42 +220,4 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.pswp) {
|
||||
--pswp-root-z-index: var(--mk-pswp-root-z-index, 2000700) !important;
|
||||
--pswp-bg: var(--MI_THEME-modalBg) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.pswp__bg {
|
||||
background: var(--MI_THEME-modalBg);
|
||||
backdrop-filter: var(--MI-modalBgFilter);
|
||||
}
|
||||
|
||||
.pswp__alt-text-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
width: 75%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.pswp__alt-text {
|
||||
color: var(--MI_THEME-fg);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding: var(--MI-margin);
|
||||
border-radius: var(--MI-radius);
|
||||
max-height: 8em;
|
||||
overflow-y: auto;
|
||||
text-shadow: var(--MI_THEME-bg) 0 0 10px, var(--MI_THEME-bg) 0 0 3px, var(--MI_THEME-bg) 0 0 3px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,8 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="$style.noteHeaderUsername">
|
||||
<MkAcct :user="appearNote.user"/>
|
||||
</div>
|
||||
<div v-if="badgeRoles" :class="$style.noteHeaderBadgeRoles">
|
||||
<img v-for="(role, i) in badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
|
||||
<div v-if="appearNote.user.badgeRoles" :class="$style.noteHeaderBadgeRoles">
|
||||
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
|
||||
@@ -251,7 +251,6 @@ import { Paginator } from '@/utility/paginator.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { $i } from '@/i.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
|
||||
// コンポーネント外部の依存関係
|
||||
@@ -333,14 +332,6 @@ provide(DI.mfmEmojiReactCallback, reactViaMfmEmoji);
|
||||
// MkNoteDetailed固有
|
||||
const tab = ref(props.initialTab);
|
||||
const reactionTabType = ref<string | null>(null);
|
||||
const badgeRoles = computed(() => {
|
||||
const roles = appearNote.user.badgeRoles;
|
||||
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
|
||||
|
||||
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
|
||||
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
|
||||
return roles.filter(role => !hiddenRoleIds.has(role.id));
|
||||
});
|
||||
|
||||
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
|
||||
limit: 10,
|
||||
|
||||
@@ -13,8 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkA>
|
||||
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
|
||||
<div :class="$style.username"><MkAcct :user="note.user"/></div>
|
||||
<div v-if="badgeRoles" :class="$style.badgeRoles">
|
||||
<img v-for="role in badgeRoles" :key="role.id" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
|
||||
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
|
||||
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
|
||||
</div>
|
||||
<div :class="$style.info">
|
||||
<div v-if="mock">
|
||||
@@ -35,26 +35,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { inject } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { DI } from '@/di.js';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const badgeRoles = computed(() => {
|
||||
const roles = props.note.user.badgeRoles;
|
||||
if (roles == null || $i == null || $i.id !== props.note.userId) return roles;
|
||||
|
||||
// 自分のプロフィールを自分で見た場合レスポンスに非表示ロールも含まれるので、別途除外する必要がある
|
||||
const hiddenRoleIds = new Set(($i.hiddenRoleIds) ?? []);
|
||||
return roles.filter(role => !hiddenRoleIds.has(role.id));
|
||||
});
|
||||
const mock = inject(DI.mock, false);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||