mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-19 07:59:02 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8da1b52c9b | |||
| 638307ff3e | |||
| 7aa0089a5a | |||
| 3129d1ac4f | |||
| 819a66d610 | |||
| dacda056fb | |||
| ab26d2b7b2 | |||
| 12f0fb7738 | |||
| abd2deae79 | |||
| 9d24906786 | |||
| 9eb9155314 | |||
| 04fd954f0e | |||
| 7ab6797513 | |||
| 988e3c32a3 | |||
| b812ddbbf2 | |||
| 1c82afbc23 | |||
| a6959a53a9 | |||
| e67df72198 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: shipping-misskey-change
|
||||
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when validation, SPDX, locale safety, migrations, misskey-js generation, or CHANGELOG checks may apply.
|
||||
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when proportional validation, SPDX, locale safety, migrations, or misskey-js generation may apply.
|
||||
---
|
||||
|
||||
# shipping-misskey-change
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| `.claude/` 内のパス | 上流パス | 上流由来 | Misskey での改変 |
|
||||
|---|---|---|---|
|
||||
| `skills/context-budget/SKILL.md` | `skills/context-budget/SKILL.md` | ECC | description を日本語化、Misskey 固有メモを追記 |
|
||||
| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、Claude が `pnpm`/`git`/`grep` で手動採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み |
|
||||
| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、repository-native command と SPDX checker で採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み |
|
||||
| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsc + ESLint + Vitest) に。Prettier/Biome フェーズを削除 |
|
||||
|
||||
### MIT License (full text)
|
||||
|
||||
@@ -10,7 +10,8 @@ frontmatter (`name` + `description` + `tools`) は、Claude が **自動でエ
|
||||
|
||||
レビュー面を増やしすぎないよう、役割を分ける:
|
||||
|
||||
- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・SPDX 形式・Storybook 併設 等)。別コンテキストで差分を機械走査する価値がある領域に限定する
|
||||
- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・Storybook 併設 等)。
|
||||
別コンテキストで差分を機械走査する価値がある領域に限定する
|
||||
- **`pr-review-toolkit` プラグイン (code-reviewer / silent-failure-hunter 等)**: 言語非依存の一般的なコード品質・バグ・設計レビュー。Misskey 固有規約は見ない
|
||||
- **`working-on-*` skill の checklist**: コードを **書いている最中** の自己チェック (レビュー専用ではなく実装ガイド)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: misskey-api-reviewer
|
||||
description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID/SPDX を検査。backend API を変更した PR レビューで呼ぶ。
|
||||
description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID を検査。backend API を変更した PR レビューで呼ぶ。
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
@@ -30,26 +30,12 @@ BASE=$(git merge-base origin/develop HEAD)
|
||||
- `packages/backend/src/server/api/endpoint-list.ts`
|
||||
- `packages/backend/test/e2e/**` (とくに `endpoints.ts` と `<area>.ts`)
|
||||
- `packages/misskey-js/src/autogen/**`
|
||||
- `CHANGELOG.md`
|
||||
|
||||
差分対象が空なら「レビュー対象の API エンドポイント変更なし」と短く報告して終了。
|
||||
|
||||
## チェックリスト
|
||||
|
||||
### 1. SPDX ヘッダー (Critical)
|
||||
|
||||
新規 `.ts` ファイル冒頭に以下があるか:
|
||||
|
||||
```
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
```
|
||||
|
||||
欠落すると CI の `spdx` ジョブが落ちる。
|
||||
|
||||
### 2. `meta` の必須・推奨フィールド (Major)
|
||||
### 1. `meta` の必須・推奨フィールド (Major)
|
||||
|
||||
[endpoints.ts の型定義](../../packages/backend/src/server/api/endpoints.ts) を真とする。
|
||||
|
||||
@@ -63,7 +49,7 @@ BASE=$(git merge-base origin/develop HEAD)
|
||||
- `res`: JSON Schema または `ref: '<EntityName>'`。各プロパティに `optional` / `nullable` が **明示** されているか。
|
||||
- `requireFile` / `secure` / `allowGet` / `cacheSec` / `description`: 該当するエンドポイントで使い分けているか。
|
||||
|
||||
### 3. `meta.errors` の UUID 検証 (Critical)
|
||||
### 2. `meta.errors` の UUID 検証 (Critical)
|
||||
|
||||
各 `errors[*].id` が:
|
||||
|
||||
@@ -78,14 +64,14 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint
|
||||
|
||||
新規エンドポイントの全 `id` を抽出して衝突を確認する。
|
||||
|
||||
### 4. `paramDef` (Major)
|
||||
### 3. `paramDef` (Major)
|
||||
|
||||
- JSON Schema 形式 (`type: 'object'`, `properties`, `required`)
|
||||
- ID 文字列は `format: 'misskey:id'`
|
||||
- `required` 配列で必須プロパティを明示
|
||||
- `as const` または `as const satisfies Schema` で型推論を効かせる (既存実装は前者多数。`as const` 自体が無く `Schema` 型注釈もない場合のみ指摘)
|
||||
|
||||
### 5. エンドポイント実装本体 (Major)
|
||||
### 4. エンドポイント実装本体 (Major)
|
||||
|
||||
- `Endpoint<typeof meta, typeof paramDef>` を継承しているか。
|
||||
- `@Injectable()` デコレータ + `export default class` 形式か (`// eslint-disable-line import/no-default-export` が必要)。
|
||||
@@ -94,7 +80,7 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint
|
||||
- 防御的アサーション・「起きるはずがない」内部不整合・テスト用 ENV ガード等の **想定外フェイルファスト** は `throw new Error('...')` で構わない。既存実装でも `admin/reset-password.ts` などが採用しているパターン (例: `cannot reset password of root`)。`meta.errors` に対応がない `throw new Error` を一律で指摘しない。
|
||||
- 同期 `throw` は許容。非同期処理での例外伝搬を確認する。
|
||||
|
||||
### 6. ★ `endpoint-list.ts` への登録 (Critical)
|
||||
### 5. ★ `endpoint-list.ts` への登録 (Critical)
|
||||
|
||||
最も忘れやすい。**忘れると 404**。[endpoint-list.ts](../../packages/backend/src/server/api/endpoint-list.ts) に 1 行追加されているか:
|
||||
|
||||
@@ -110,7 +96,7 @@ grep -F "'<category>/<name>'" packages/backend/src/server/api/endpoint-list.ts
|
||||
|
||||
**並び順の補足**: ファイル全体は厳密なアルファベット順では並んでおらず、同カテゴリ内 (`admin/queue/*` など) でも追加された経緯どおりの順になっている箇所が多い。**順序逸脱は指摘根拠にしない** (誤検知の元)。「行が存在するか」のみを Critical 観点として扱う。
|
||||
|
||||
### 7. `misskey-js` 再生成 (Critical)
|
||||
### 6. `misskey-js` 再生成 (Critical)
|
||||
|
||||
`meta` / `paramDef` / `res` を変更したら、PR / ブランチに `packages/misskey-js/src/autogen/` 配下の差分が含まれているか確認する:
|
||||
|
||||
@@ -121,22 +107,12 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/
|
||||
|
||||
差分ゼロなら `pnpm build-misskey-js-with-types` の実行漏れ。CI の `check-misskey-js-autogen` ワークフローで必ず落ちるため Critical 扱い。
|
||||
|
||||
### 8. e2e テスト (Major)
|
||||
### 7. e2e テスト (Major)
|
||||
|
||||
[test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) または `test/e2e/<area>.ts` (`note.ts`, `users.ts` 等) 配下に、対応する `api('<category>/<name>', ...)` 呼び出しを含む `test(...)` ケースが追加されているか確認する。複雑な分岐 (権限チェック・エラーケース) の網羅も確認する。
|
||||
|
||||
**describe ラベルの形式は問わない**: 既存テストは `describe('Note', () => { test('投稿できる', ...) })` のように人間可読ラベルで構造化されており、`<category>/<name>` 形式の describe は使われていない。describe 名の規約違反としては指摘しない。
|
||||
|
||||
### 9. CHANGELOG エントリ (Minor)
|
||||
|
||||
ユーザー影響がある (新エンドポイント / 既存挙動変更) 場合、`CHANGELOG.md` の `## Unreleased` → `### Server` に 1 行追加されているか確認する。
|
||||
|
||||
```
|
||||
- Feat: /api/<category>/<name> を追加
|
||||
```
|
||||
|
||||
純粋な内部リファクタなら不要。
|
||||
|
||||
## 出力形式
|
||||
|
||||
優先度別に以下のフォーマットで出力する。
|
||||
@@ -166,4 +142,4 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/
|
||||
- [endpoint-base.ts (Endpoint 基底クラス)](../../packages/backend/src/server/api/endpoint-base.ts)
|
||||
- [error.ts (ApiError)](../../packages/backend/src/server/api/error.ts)
|
||||
- [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts)
|
||||
- [AGENTS.md](../../AGENTS.md) — SPDX / マイグレーション履歴 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)
|
||||
- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: vue-component-reviewer
|
||||
description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。SPDX (HTML コメント)・Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。
|
||||
description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。
|
||||
tools: Read, Grep, Glob, Bash
|
||||
---
|
||||
|
||||
@@ -29,40 +29,26 @@ BASE=$(git merge-base origin/develop HEAD)
|
||||
|
||||
- `locales/*.yml` (とくに `ja-JP.yml` 以外の変更は即 Critical)
|
||||
- `packages/frontend/src/components/**/*.stories.impl.ts`
|
||||
- `CHANGELOG.md`
|
||||
|
||||
差分対象が空なら「レビュー対象の Vue コンポーネント変更なし」と短く報告して終了。
|
||||
|
||||
## チェックリスト
|
||||
|
||||
### 1. SPDX ヘッダー (Critical)
|
||||
|
||||
`.vue` ファイル冒頭は **HTML コメント形式** で必須:
|
||||
|
||||
```html
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
```
|
||||
|
||||
`/* ... */` (TS 形式) は禁止 (CI の `spdx` ジョブはコメント形式ではなく SPDX 文字列の有無のみを検査するため、形式が違っても CI は通るが、規約違反として指摘する)。形式の根拠は references/knowledge 側を参照。
|
||||
|
||||
### 2. 命名規約 (Major)
|
||||
### 1. 命名規約 (Major)
|
||||
|
||||
- 共有 / 再利用コンポーネント (`packages/frontend/src/components/` 配下、サブディレクトリ含む) は `Mk` プレフィックス必須 (例: `MkButton.vue`, `global/MkAvatar.vue`, `grid/MkGrid.vue`)。
|
||||
- ページ固有のものは `pages/` 配下に置き、`Mk` プレフィックスは不要。
|
||||
|
||||
**補足:** `<script setup>` SFC は named export を持たないため、「ファイル名と export 名の一致」を機械的に検査することはできない。SFC のデフォルトエクスポートはコンパイラ生成なので、ファイル名規約のみを基準にする。
|
||||
|
||||
### 3. `<script>` タグ (Major)
|
||||
### 2. `<script>` タグ (Major)
|
||||
|
||||
- `<script lang="ts" setup>` または `<script setup lang="ts">` のどちらでもよい (既存コードは多数派が前者だが、後者も `MkThemePreview.vue` 等で使われている)。属性順は指摘しない。`lang="ts"` が **無い** ものは指摘する。
|
||||
- 型ジェネリックが必要なら `generic="T extends ..."` 属性を加える (順序問わず)。
|
||||
- `defineProps<{ ... }>()` / `defineEmits<{ ... }>()` は **type-only** 形式。runtime の object 形式 (`defineProps({ ... })`) は使わない。
|
||||
- Options API (`export default { data() { ... } }`) は禁止。
|
||||
|
||||
### 4. i18n の使い分け (Critical)
|
||||
### 3. i18n の使い分け (Critical)
|
||||
|
||||
- 文字列リテラルの直書き禁止 (テンプレート / JS 両方)。
|
||||
- 引数なし: `i18n.ts.<path>` (例: `i18n.ts.deleted`)。
|
||||
@@ -77,7 +63,7 @@ BASE=$(git merge-base origin/develop HEAD)
|
||||
git diff --name-only "$BASE"...HEAD -- 'locales/*.yml' | grep -v 'ja-JP.yml'
|
||||
```
|
||||
|
||||
### 5. スタイル (Major)
|
||||
### 4. スタイル (Major)
|
||||
|
||||
- `<style lang="scss" module>` を既定とし、`:class="$style.foo"` で参照する。
|
||||
- 新規で `<style scoped>` (module なし) は使わない (legacy)。
|
||||
@@ -94,7 +80,7 @@ git diff "$BASE"...HEAD -- 'packages/frontend/src/**/*.vue' \
|
||||
| grep -E '^\+' | grep -E '#[0-9a-fA-F]{3,8}\b|rgba?\('
|
||||
```
|
||||
|
||||
### 6. UI 操作は `os.*` 経由 (Critical)
|
||||
### 5. UI 操作は `os.*` 経由 (Critical)
|
||||
|
||||
- 直接の `alert()` / `confirm()` / `window.prompt()` / `window.alert()` は禁止。
|
||||
- `os.alert` / `os.confirm` / `os.popup` / `os.toast` / `os.popupMenu` / `os.contextMenu` / `os.form` / `os.apiWithDialog` を使う ([os.ts](../../packages/frontend/src/os.ts) 参照)。
|
||||
@@ -107,14 +93,14 @@ git diff "$BASE"...HEAD -- 'packages/frontend/src/**/*.vue' \
|
||||
| grep -E '^\+' | grep -E '\b(alert|confirm|prompt)\s*\('
|
||||
```
|
||||
|
||||
### 7. アクセシビリティ (Major)
|
||||
### 6. アクセシビリティ (Major)
|
||||
|
||||
- クリック可能要素は `<button>` か、`role="button"` + `tabindex="0"` + キーボードハンドラ (`@keydown.enter` 等) を実装する。
|
||||
- 装飾以外の `<div @click>` で a11y 配慮がないものは指摘する。
|
||||
- フォーム要素には対応する `<label>` または `aria-label` を付ける。
|
||||
- `:disabled` バインドや `aria-disabled` の整合性を確認する。
|
||||
|
||||
### 8. Storybook 併設 (Major)
|
||||
### 7. Storybook 併設 (Major)
|
||||
|
||||
- 共有 `Mk*` コンポーネントを新規追加した場合、`Mk<Name>.stories.impl.ts` が同階層に併設されているか (サブディレクトリ含む。例: `components/global/MkAvatar.stories.impl.ts`, `components/grid/MkGrid.stories.impl.ts`)。
|
||||
- **ファイル名は `.stories.impl.ts` 固定** (`.stories.ts` は生成物なので手編集・コミット不可)。
|
||||
@@ -130,31 +116,20 @@ git diff --name-only --diff-filter=A "$BASE"...HEAD -- \
|
||||
| xargs -I {} sh -c 'test -f {} || echo "missing: {}"'
|
||||
```
|
||||
|
||||
### 9. アイコン (Minor)
|
||||
### 8. アイコン (Minor)
|
||||
|
||||
- アイコンは Tabler icons クラス (`<i class="ti ti-info-circle">` 等) を使う。
|
||||
- インライン SVG や別アイコンセットは原則使わない (既存パターンに合わせる)。
|
||||
|
||||
### 10. CHANGELOG エントリ (Minor)
|
||||
|
||||
ユーザー影響がある変更なら、`CHANGELOG.md` の `## Unreleased` → `### Client` に 1 行追加されているか確認する。
|
||||
|
||||
```
|
||||
- Enhance: <component> の <挙動> を改善
|
||||
- Fix: <component> の <不具合> を修正
|
||||
```
|
||||
|
||||
純粋な内部リファクタなら不要。
|
||||
|
||||
## 出力形式
|
||||
|
||||
優先度別に以下のフォーマットで出力する。
|
||||
|
||||
```
|
||||
## 🔴 Critical
|
||||
- packages/frontend/src/components/MkFoo.vue:1
|
||||
SPDX ヘッダーが HTML コメント形式ではなく TS 形式になっている。
|
||||
`<!-- ... -->` で書き直すこと。
|
||||
- packages/frontend/src/components/MkFoo.vue:42
|
||||
ブラウザ組み込みの `alert()` を直接呼んでいる。
|
||||
`os.alert()` を使用すること。
|
||||
|
||||
## 🟡 Major
|
||||
- ...
|
||||
@@ -175,4 +150,4 @@ git diff --name-only --diff-filter=A "$BASE"...HEAD -- \
|
||||
- [MkButton.vue](../../packages/frontend/src/components/MkButton.vue)
|
||||
- [MkInput.vue](../../packages/frontend/src/components/MkInput.vue) — generic SFC 例
|
||||
- [MkButton.stories.impl.ts](../../packages/frontend/src/components/MkButton.stories.impl.ts) — Storybook 雛形
|
||||
- [AGENTS.md](../../AGENTS.md) — SPDX / locales 編集制限 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)
|
||||
- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針
|
||||
|
||||
@@ -12,7 +12,9 @@ upstream path: commands/harness-audit.md
|
||||
upstream license: MIT — https://github.com/affaan-m/everything-claude-code/blob/main/LICENSE
|
||||
project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サードパーティ一覧 + MIT 全文)
|
||||
|
||||
Imported into Misskey .claude/ on 2026-05-10. The 7-category rubric and output contract are derived from the upstream ECC version (MIT). The runtime layer was substantially reimplemented for Misskey: the upstream relies on scripts/harness-audit.js to mechanically score, while this version asks Claude to score directly with pnpm/git/grep, and adds Misskey-specific evaluation axes (SPDX coverage / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合).
|
||||
Imported into Misskey .claude/ on 2026-05-10.
|
||||
The 7-category rubric and output contract are derived from the upstream ECC version (MIT).
|
||||
The runtime layer was substantially reimplemented for Misskey: the upstream relies on scripts/harness-audit.js to mechanically score, while this version uses repository-native commands and the SPDX checker, and adds Misskey-specific evaluation axes (SPDX coverage / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合).
|
||||
|
||||
note: 元 ECC 版は scripts/harness-audit.js (専用 Node スクリプト) で機械採点していたが、Misskey は ECC plugin runtime に依存しない方針なので、Claude が直接ファイルを読んで採点する手動運用版に書き換えた。Misskey 固有の重要観点 (SPDX 適用率 / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合) を評価軸として明示的に組み込んでいる。
|
||||
-->
|
||||
@@ -33,10 +35,10 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
|
||||
| --- | --- | --- |
|
||||
| 1 | Tool Coverage | skill / agent / command の数、欠けているワークフロー段、重複なし |
|
||||
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
|
||||
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
|
||||
| 3 | Quality Gates | 変更ファイル lint、`/quality-gate`、変更別 test / typecheck、CI gate との整合 |
|
||||
| 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/Playwright 適用ガイド |
|
||||
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
|
||||
| 6 | Security Guardrails | SPDX checker、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
|
||||
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |
|
||||
|
||||
## Misskey 固有の確認項目 (採点根拠コマンド)
|
||||
@@ -44,18 +46,9 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
|
||||
採点時に以下を実コマンドで確認する。各項目の **属するカテゴリ** は項目内に明記する (#1-#3 は Security Guardrails、#4 は Tool Coverage、#5 は Quality Gates):
|
||||
|
||||
```bash
|
||||
# 1. [Security Guardrails] SPDX 適用率 (新規ファイル想定の汎用チェック)
|
||||
# - node_modules を prune で除外
|
||||
# - packages/misskey-js は MIT サブパッケージなので AGPL ヘッダーを持たない (AGENTS.md §1) → 除外
|
||||
# - built/ なども除外
|
||||
# 候補にはなお *.config.{ts,js} / *eslint* / *.d.ts のような CI 上 SPDX 対象外
|
||||
# (.github/workflows/check-spdx-license-id.yml の exclude 参照) も混ざるため、
|
||||
# 上位に出たファイルが「新規追加した実コード」かどうかは目視判定する。
|
||||
find packages \
|
||||
\( -type d \( -name node_modules -o -name built -o -name dist -o -path 'packages/misskey-js' \) -prune \) \
|
||||
-o -type f \( -name '*.ts' -o -name '*.js' -o -name '*.vue' -o -name '*.scss' \) -print \
|
||||
| xargs -r grep -L 'SPDX-License-Identifier: AGPL-3.0-only' | head -20
|
||||
# → 上位に新規実コードが無ければ満点
|
||||
# 1. [Security Guardrails] SPDX 対象・CI 判定・HTML コメント形式
|
||||
node scripts/check-spdx.mjs
|
||||
# → exit 0 (`SPDX: OK`) なら満点。追加の目視判定はしない
|
||||
|
||||
# 2. [Security Guardrails] ja-JP.yml 以外の locales が直近で手動編集されていないか
|
||||
# --pretty=format: でコミットヘッダ行を抑止し、ファイル名行のみを残してから grep する。
|
||||
@@ -105,20 +98,19 @@ grep -rn 'console\.\(log\|debug\)' packages/backend/src packages/frontend/src 2>
|
||||
## サンプル出力
|
||||
|
||||
```text
|
||||
Harness Audit (repo): 55/70
|
||||
Harness Audit (repo): 52/70
|
||||
|
||||
Tool Coverage: 9/10 (skills 5, agents 2, commands 5 — 偏りなし)
|
||||
Context Efficiency: 8/10 (description 平均 3-5 行、肥大なし)
|
||||
Quality Gates: 5/10 (Stop hook 共有設定に未登録 / `/quality-gate` あり)
|
||||
Quality Gates: 7/10 (変更ファイル lint / `/quality-gate` あり、広域 gate は任意)
|
||||
Memory Persistence: 5/10 (プロジェクト側 memory/ 未採用方針 = 既定値)
|
||||
Eval Coverage: 7/10 (backend/frontend testing リファレンス網羅、Storybook 一部抜け)
|
||||
Security Guardrails: 10/10 (SPDX 100%, locales OK, migrations clean)
|
||||
Security Guardrails: 8/10 (SPDX 欠落 1 件、locales OK、migrations clean)
|
||||
Cost Efficiency: 8/10 (context-budget 導入済 / MCP 0)
|
||||
|
||||
Failed Checks:
|
||||
- packages/frontend/src/.../X.vue で SPDX 欠落 (Security Guardrails)
|
||||
- console.log が backend に 3 件 (Quality Gates)
|
||||
- 共有 Stop hook なし (Quality Gates) — 各 contributor が `.claude/settings.local.json` で opt-in する方針なら減点しなくて良い
|
||||
|
||||
Top 3 Actions:
|
||||
1) [Security Guardrails] SPDX 欠落 1 ファイルを修正:
|
||||
@@ -137,10 +129,10 @@ Suggested next skills to apply:
|
||||
|
||||
- 確定的: 同じ commit / 同じ `.claude/` 構成なら同じスコア
|
||||
- ヒューリスティクス: 「description の冗長度」のような主観項目は同一基準で機械的に判定
|
||||
- スクリプト不要: `pnpm` と `git`、`grep`/`find` 等の標準ツールのみ
|
||||
- 専用 audit script 不要: repository の `pnpm` / `git` コマンドと SPDX checker を使う
|
||||
|
||||
## 参考: ECC オリジナルとの差分
|
||||
|
||||
- ECC 版は `node scripts/harness-audit.js` を直叩きする運用で、ECC リポジトリ全体に閉じた採点だった。
|
||||
- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、`pnpm` ベースの実コマンドで根拠を取る方式に再設計。
|
||||
- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、repository-native command で根拠を取る方式に再設計。
|
||||
- 結果として ECC への依存はゼロ。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Misskey の lint / typecheck / 高速テストを順に実行して品質ゲートを通すコマンド。完了前の軽量検証用。
|
||||
description: Misskey の lint / typecheck / 高速テストを順に実行する任意の広域品質検証。
|
||||
argument-hint: "[repo|backend|frontend|<path/to/file.ts>]"
|
||||
---
|
||||
|
||||
@@ -14,33 +14,35 @@ project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サード
|
||||
|
||||
Imported into Misskey .claude/ on 2026-05-10. Pipeline 概念 (lint → typecheck → test) は upstream ECC 版から借用 (MIT)。実コマンド層は Misskey の pnpm + tsc + ESLint + Vitest に固定し、formatter (Prettier/Biome) フェーズは削除した。
|
||||
|
||||
note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。重い test:e2e / test:fed は含まない (CI 側で実行される)。
|
||||
note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。
|
||||
重い test:e2e / test:fed は含めず、変更内容または明示依頼に応じて個別実行する。
|
||||
-->
|
||||
|
||||
# /quality-gate — Misskey 軽量品質ゲート
|
||||
# /quality-gate — Misskey 広域品質検証
|
||||
|
||||
`/quality-gate [scope]`
|
||||
|
||||
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない。
|
||||
package または repo 全体の状態が必要なときに任意で使う。
|
||||
完了時に必須の変更ファイル lint は [shipping-misskey-change](../skills/shipping-misskey-change/SKILL.md) が担当する。
|
||||
|
||||
## Scope
|
||||
|
||||
- `repo` (default) — 全パッケージ
|
||||
- `repo` (default) — 全 workspace の lint + backend / frontend の unit test
|
||||
- `backend` — `packages/backend` のみ
|
||||
- `frontend` — `packages/frontend` のみ
|
||||
- `path/to/file.ts` — 単一ファイルへの ESLint --fix のみ
|
||||
- `path/to/file.ts` — 単一ファイルへの ESLint `--quiet` のみ
|
||||
|
||||
## Pipeline
|
||||
|
||||
### Repo scope (全部)
|
||||
### Repo scope
|
||||
|
||||
各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json)) で、ルートの `pnpm lint` は `pnpm --no-bail -r lint` (= 全パッケージで lint を `--no-bail` で実行)。**typecheck は lint に含まれている**ため、通常はこの 2 コマンドで十分:
|
||||
各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json))。
|
||||
ルートの `pnpm lint` は `pnpm --no-bail -r lint && pnpm check-dts` なので、そのまま実行すると workspace lint の失敗時に `check-dts` が実行されない。
|
||||
次の 4 コマンドをそれぞれ独立した Bash 呼び出しとして実行し、先の失敗にかかわらず全結果を収集する:
|
||||
|
||||
```bash
|
||||
# 1. Lint (= typecheck + ESLint、全パッケージ。--no-bail で最初の失敗で止まらず全結果を集める)
|
||||
pnpm lint
|
||||
|
||||
# 2. Unit test (高速、e2e は含まない)
|
||||
pnpm --no-bail -r lint
|
||||
pnpm check-dts
|
||||
pnpm --filter backend test
|
||||
pnpm --filter frontend test
|
||||
```
|
||||
@@ -56,7 +58,8 @@ pnpm --filter frontend typecheck # vue-tsc 単体 (Vue SFC の型を見るた
|
||||
|
||||
### Backend scope
|
||||
|
||||
`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。軽量ゲートでは typecheck の二重実行を避けるため `lint` + `test` のみ:
|
||||
`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。
|
||||
広域検証では typecheck の二重実行を避けるため `lint` + `test` のみ:
|
||||
|
||||
```bash
|
||||
pnpm --filter backend lint
|
||||
@@ -67,7 +70,7 @@ pnpm --filter backend test
|
||||
|
||||
### Frontend scope
|
||||
|
||||
`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、軽量ゲートでは Backend 同様に `lint` + `test` のみ:
|
||||
`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、広域検証では Backend 同様に `lint` + `test` のみ:
|
||||
|
||||
```bash
|
||||
pnpm --filter frontend lint
|
||||
@@ -78,46 +81,38 @@ pnpm --filter frontend test
|
||||
|
||||
### Single file scope
|
||||
|
||||
repo-relative path を package-relative path に変換し、該当 package root で実行する。
|
||||
|
||||
```bash
|
||||
pnpm exec eslint --fix <path>
|
||||
(cd packages/backend && pnpm exec eslint --quiet -- src/path/to/file.ts)
|
||||
(cd packages/frontend && pnpm exec eslint --quiet -- src/path/to/component.vue)
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
実行したフェーズの pass/fail と件数を集計する。標準パイプラインは `pnpm lint` (typecheck 内包) と unit test のみなので、デフォルトの出力は以下のようになる:
|
||||
各コマンドの終了コードを保持し、実行項目を `PASS / FAIL / BASELINE / SKIPPED` で集計する。
|
||||
`BASELINE` は同じ失敗が base 側でも再現し、今回の変更と無関係と確認できた場合だけ使う。
|
||||
|
||||
```text
|
||||
Quality Gate (repo):
|
||||
|
||||
Lint: PASS (0 errors, 2 warnings)
|
||||
Backend ut: PASS (412/412)
|
||||
Frontend ut: PASS (87/87)
|
||||
|
||||
→ 完了前の軽量チェック OK。重い e2e / 連合テストは CI 側で実行される。
|
||||
Lint: PASS
|
||||
Backend ut: BASELINE (base 側でも同じ既存失敗)
|
||||
Frontend ut: PASS
|
||||
Other tests: SKIPPED (repo scope の対象外)
|
||||
```
|
||||
|
||||
`#### 詳細を分けて見たい時のみ (optional)` で個別 typecheck (`pnpm --filter backend typecheck` / `pnpm --filter frontend typecheck`) も回した場合のみ、その結果を追加行として表示する:
|
||||
|
||||
```text
|
||||
Quality Gate (repo):
|
||||
|
||||
Lint: PASS (0 errors, 2 warnings)
|
||||
Backend tc: PASS (0 errors) # optional 実行時のみ
|
||||
Frontend tc: PASS (0 errors) # optional 実行時のみ
|
||||
Backend ut: PASS (412/412)
|
||||
Frontend ut: PASS (87/87)
|
||||
```
|
||||
|
||||
失敗時は最初に落ちたフェーズで停止して詳細を見せる。
|
||||
一つが失敗しても独立した残りの検証は続ける。
|
||||
`BASELINE` を `PASS` と表示せず、未実行の項目は理由とともに `SKIPPED` とする。
|
||||
|
||||
## 関連 skill / コマンド
|
||||
|
||||
- [`shipping-misskey-change` スキル](../skills/shipping-misskey-change/SKILL.md) — commit / PR 直前の最終チェックリスト (misskey-js 再生成 / SPDX / CHANGELOG 等)
|
||||
- [`shipping-misskey-change` スキル](../skills/shipping-misskey-change/SKILL.md) — commit / PR 直前の最終チェックリスト
|
||||
- [`shipping-misskey-change/references/tasks/regenerate-misskey-js.md`](../skills/shipping-misskey-change/references/tasks/regenerate-misskey-js.md) — API 変更時の `pnpm build-misskey-js-with-types` 実行手順
|
||||
- [.github/copilot-instructions.md §Validation コマンド](../../.github/copilot-instructions.md) — pnpm コマンド一覧 (Copilot / Codex 向けに再掲)
|
||||
|
||||
## 元 ECC 版との差分
|
||||
|
||||
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
|
||||
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
|
||||
- e2e / federation / Playwright は重いため除外し CI 側に委譲。
|
||||
- formatter フェーズなし (変更ファイル lint は ESLint `--quiet`)。
|
||||
- e2e / federation / Playwright は scope に自動追加せず、変更内容または明示依頼に応じて個別実行。
|
||||
|
||||
@@ -1,33 +1,53 @@
|
||||
---
|
||||
name: shipping-misskey-change
|
||||
description: Use at every "finish" moment of a Misskey change — immediately before committing, opening a PR, merging, or handing the work back to the user even without a commit. Runs the final pre-ship checklist — `pnpm lint`, misskey-js regeneration (`pnpm build-misskey-js-with-types`) when backend API changed, `pnpm --filter backend check-migrations` when entities or migrations changed, SPDX header verification on new files, locale safety check (no edits to non-`ja-JP` locale yml files), and `CHANGELOG.md` Unreleased entry for user-visible changes. Must be consulted as the last step of every change — including uncommitted handoffs — to avoid CI failures and lost translations. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this regardless of what preceded it.
|
||||
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back. Selects proportional validation, runs changed-file lint and repository safety checks, and records PASS/FAIL/BASELINE/SKIPPED without chasing unrelated failures.
|
||||
---
|
||||
|
||||
# shipping-misskey-change
|
||||
|
||||
Misskey の変更の **finish 局面** (commit / PR / merge する直前、またはコミットせずユーザーに作業を返す直前) に必ず走らせる最終チェックリスト。
|
||||
Misskey の変更を commit / PR / merge する直前、または未commitでユーザーへ返す直前の出口。
|
||||
規範は [AGENTS.md](../../../AGENTS.md)、ここでは実行方法だけを定める。
|
||||
|
||||
CI で落ちやすい / レビュアーから指摘されやすいポイントを 1 箇所に集めている。後で references を辿る余裕を作らないため、チェックリストは SKILL.md 本体に直書きする。
|
||||
## 1. 検証レベル
|
||||
|
||||
**他スキル実行後も免除されない。** `brainstorming` / `writing-plans` / その他アップストリームスキルを先に呼んでいても、作業を返す直前・commit 直前のタイミングでこのスキルを呼ぶこと。
|
||||
| 段 | 条件 | 実行 |
|
||||
| --- | --- | --- |
|
||||
| 1 (必須) | package の ESLint 対象ファイルを変更 | 存在する変更ファイルへ `eslint --quiet` を最後に 1 回 |
|
||||
| 2 | 実装・挙動を変更 | 最も近い unit test を実行。型・生成物・DB に関係するときは対応する専用検証も実行 |
|
||||
| 3 (任意) | 明示依頼、広域変更、切り分けに必要 | package / repo 全体の lint、build、広域 test |
|
||||
|
||||
## 最終チェックリスト
|
||||
段 1 は docs-only など対象が空なら `SKIPPED`。
|
||||
段 3 の既存失敗は成功扱いせず `BASELINE` として、今回の変更との関係だけを報告する。
|
||||
|
||||
このリストを TodoWrite に展開して 1 項目ずつ確認すること。**該当しない項目は飛ばして良いが、判断は明示する**。
|
||||
### 自動検査
|
||||
|
||||
- [ ] lint が通る — ECC 由来の [/quality-gate](../../commands/quality-gate.md) コマンドで lint (typecheck + eslint) + 高速テストをまとめて回すのが基本。lint だけ単発で確認したいなら `pnpm lint` 直接でもよい
|
||||
- [ ] backend で `meta` / `paramDef` / `res` を変更した → `pnpm build-misskey-js-with-types` を実行して `packages/misskey-js/src/autogen/` の差分も commit に含めた → 詳細手順は [references/tasks/regenerate-misskey-js.md](references/tasks/regenerate-misskey-js.md)
|
||||
- [ ] エンティティ (`packages/backend/src/models/*.ts` の `@Column` / `@Entity` / `@Index`) を変更した → `pnpm --filter backend check-migrations` が pending DDL 0 件で通る
|
||||
- [ ] migration ファイルを追加した → `up()` と `down()` の両方を実装した / 既存のマージ済 migration は一切触っていない
|
||||
- [ ] 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイルを追加した → SPDX ヘッダーを付けた (`.vue` / `.html` は HTML コメント形式、その他は TS コメント形式)
|
||||
- [ ] `locales/` を編集した → **`ja-JP.yml` だけ** を変更しており、他言語 yml の diff は出ていない (`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空)
|
||||
- [ ] ユーザーから見える変更 (機能追加 / 既存挙動変更) → `CHANGELOG.md` の `## Unreleased` 直下の該当サブセクション (General / Client / Server) に 1 行追記した → 詳細書式は [references/tasks/changelog-update.md](references/tasks/changelog-update.md)
|
||||
- [ ] backend API endpoint を追加・変更した → [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) agent を Task で起動して機械レビューする (endpoint-list 登録漏れ / misskey-js 再生成漏れ / meta・UUID / SPDX。lint や CI では拾いにくい 404・登録漏れの最終関門なので、該当する変更があれば飛ばさない)
|
||||
- [ ] frontend の `.vue` を追加・変更した → [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent を Task で起動して機械レビューする (SPDX 形式 / 命名 / i18n / SCSS 変数 / os.* / a11y / Storybook 併設)
|
||||
- [ ] (任意) `.claude/` ハーネス自体の健全性を確認したい → ECC 由来の [/harness-audit](../../commands/harness-audit.md) コマンドを実行
|
||||
repo root で次を 1 回実行する。
|
||||
|
||||
## 何のためのスキルか
|
||||
```bash
|
||||
node scripts/check-shipping.mjs
|
||||
```
|
||||
|
||||
これは「**作業中に何を作るか**」を決めるスキルではなく、「**作り終わった後に CI を通す**」スキル。`working-on-backend` / `working-on-frontend` から始まった作業の **出口** として機能する。
|
||||
統合先を明示する場合は `--base <ref>`、または `MISSKEY_BASE_REF` を使う。
|
||||
script は次を行い、独立した検査を最後まで続けて exit 0 (合格) / 1 (違反) / 2 (検査不能) に集約する。
|
||||
|
||||
該当する変更がある場合は各 references/tasks/ を Read して詳細手順を踏むこと。`pnpm lint` だけは references を読まずに直接走らせて良い (`/quality-gate` でまとめて回せる)。
|
||||
- commit 済み・未commit・untracked の変更集合を NUL-safe に列挙し、変更ファイルだけへ package root から `eslint --quiet` を実行
|
||||
- SPDX 違反時はローカル変更の欠落だけ `check-spdx.mjs --fix` で補い、通常検査を再実行
|
||||
- SPDX の結果にかかわらず、`locales/ja-JP.yml` 以外の locale YAML 変更を検査
|
||||
|
||||
`SPDX: OK` 後は追加確認しない。
|
||||
その他の常設方針は AGENTS.md に従う。
|
||||
|
||||
## 2. 変更別チェック
|
||||
|
||||
- backend API の `meta` / `paramDef` / `res`: `pnpm build-misskey-js-with-types`。
|
||||
手順は [regenerate-misskey-js.md](references/tasks/regenerate-misskey-js.md)
|
||||
- entity / migration: `pnpm --filter backend check-migrations`。
|
||||
新規 migration は `up()` / `down()`、既存のマージ済 migration は差分なし
|
||||
- backend API endpoint: [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) を実行
|
||||
- frontend `.vue`: [vue-component-reviewer](../../agents/vue-component-reviewer.md) を実行
|
||||
|
||||
## 3. 引き継ぎ
|
||||
|
||||
実行項目を `PASS / FAIL / BASELINE / SKIPPED` で短く列挙する。
|
||||
失敗時は今回の変更との関係、未実行時は理由を書く。
|
||||
ユーザーが依頼していない commit / PR / 外部送信は行わない。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# CHANGELOG.md の Unreleased セクションに 1 行追記する
|
||||
# 明示依頼時に CHANGELOG.md の Unreleased セクションを更新する
|
||||
|
||||
ユーザー影響のある変更 (機能追加・修正・改善) は `CHANGELOG.md` の冒頭 `## Unreleased` セクションに 1 行追加する。リファクタリング等の内部変更は不要。
|
||||
ユーザーから CHANGELOG 編集を明示的に依頼された場合だけ読む書式リファレンス。
|
||||
通常の実装・修正では編集しない。
|
||||
|
||||
## セクション構造
|
||||
|
||||
|
||||
@@ -30,6 +30,6 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
|
||||
|
||||
## 必ず最後に通る場所
|
||||
|
||||
backend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。`pnpm lint` / misskey-js 再生成 / `check-migrations` / SPDX / CHANGELOG をまとめて確認する。
|
||||
backend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。
|
||||
|
||||
API endpoint を追加・変更したなら、その出口で [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、endpoint-list 登録漏れや misskey-js 再生成漏れを取りこぼしにくい。
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
2. 実装 : meta / paramDef / クラス本体を書く (SPDX ヘッダー付き)
|
||||
3. 配線 : endpoint-list.ts に登録 (★ 忘れると 404)
|
||||
4. 検証 : e2e テスト + lint + misskey-js 再生成
|
||||
5. 仕上げ : CHANGELOG エントリ (shipping-misskey-change で確認)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -254,12 +253,6 @@ PR に `packages/misskey-js/src/autogen/` 配下の差分が含まれていな
|
||||
|
||||
---
|
||||
|
||||
## 5. 仕上げフェーズ — CHANGELOG
|
||||
|
||||
ユーザー影響がある (新機能 / 既存挙動変更) なら `CHANGELOG.md` の `## Unreleased` → `### Server` に 1 行追加する。詳細は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) に従う。
|
||||
|
||||
---
|
||||
|
||||
## 落とし穴サマリ (PR で頻発するミス)
|
||||
|
||||
詳細な症状 → 原因 → 修正 のフォーマット → **[knowledge/api-meta-paramdef.md](../knowledge/api-meta-paramdef.md) §落とし穴**
|
||||
|
||||
@@ -161,12 +161,6 @@ pnpm migrate
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG (ユーザー影響がある場合)
|
||||
|
||||
スキーマ変更がユーザーに見える挙動を生む場合のみ、`CHANGELOG.md` に追記する。内部リファクタや純粋なインデックス追加は不要。詳細は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で確認。
|
||||
|
||||
---
|
||||
|
||||
## 提出前セルフレビューチェックリスト
|
||||
|
||||
完了前に以下を上から確認する (各項目を TodoWrite 化してよい):
|
||||
@@ -177,4 +171,3 @@ pnpm migrate
|
||||
- [ ] `up()` の各文に対応する巻き戻しが `down()` にあり、**`down()` が空でない** (難ケースは [knowledge/typeorm-patterns.md](../knowledge/typeorm-patterns.md) を確認済み)
|
||||
- [ ] `pnpm --filter backend check-migrations` が **0 件 (pending DDL なし)** で通る
|
||||
- [ ] (可能なら) `pnpm migrate` → `pnpm revert` → `pnpm migrate` が通る
|
||||
- [ ] ユーザーに見える変更なら CHANGELOG 追記 → [shipping-misskey-change](../../../shipping-misskey-change/SKILL.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: working-on-frontend
|
||||
description: Use whenever editing or adding code under `packages/frontend/`, or editing `locales/ja-JP.yml` for frontend-facing UI text — including Vue 3 SFCs (`Mk*` components), i18n keys (`i18n.ts.<key>` / `i18n.tsx.<key>()`), SCSS Modules, theme/CSS variables, `os.*` UI helpers, and Storybook stories. Covers SPDX (HTML comment form), `<script setup lang="ts">` conventions, type-only defineProps, `ja-JP.yml`-only locale editing (other locale yml files are Crowdin-managed and must not be edited), and accessibility. Must be consulted before any frontend or UI-locale change to avoid CI failures, lost translations, and reviewer pushback. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this at implementation time regardless of what preceded it.
|
||||
description: Use whenever editing or adding code under `packages/frontend/`, or editing `locales/ja-JP.yml` for frontend-facing UI text — including Vue 3 SFCs (`Mk*` components), i18n keys (`i18n.ts.<key>` / `i18n.tsx.<key>()`), SCSS Modules, theme/CSS variables, `os.*` UI helpers, and Storybook stories. Covers `<script setup lang="ts">` conventions, type-only defineProps, `ja-JP.yml`-only locale editing (other locale yml files are Crowdin-managed and must not be edited), and accessibility. Must be consulted before any frontend or UI-locale change to avoid CI failures, lost translations, and reviewer pushback. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this at implementation time regardless of what preceded it.
|
||||
---
|
||||
|
||||
# working-on-frontend
|
||||
@@ -31,6 +31,6 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
|
||||
|
||||
## 必ず最後に通る場所
|
||||
|
||||
frontend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。`pnpm lint` / SPDX / `ja-JP.yml` のみ編集確認 / CHANGELOG をまとめて確認する。
|
||||
frontend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。
|
||||
|
||||
`.vue` を追加・変更したなら、その出口で [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、SPDX 形式・命名・i18n・SCSS 変数・a11y・Storybook 併設の逸脱を取りこぼしにくい。
|
||||
`.vue` を追加・変更したなら、その出口で [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、命名・i18n・SCSS 変数・a11y・Storybook 併設の逸脱を取りこぼしにくい。
|
||||
|
||||
@@ -227,18 +227,6 @@ git checkout HEAD -- locales/zh-CN.yml
|
||||
|
||||
PR 化前なら何度でもやり直せる。**マージしてしまうと Crowdin 側との整合性が崩れて手動回復が必要** になるので、PR レビュー段階で必ず `locales/*.yml` (ja-JP 以外) の diff がゼロであることを確認する。
|
||||
|
||||
### CHANGELOG 記載の判定
|
||||
|
||||
| 変更内容 | CHANGELOG 記載 |
|
||||
|---|---|
|
||||
| 新規画面追加と一緒に新キー追加 | 必要 (`### Client` に Feat/Enhance) |
|
||||
| 既存文言の改善 (誤字脱字以外) | 必要 (`### Client` に Enhance) |
|
||||
| 誤字脱字・微妙な言い回し修正 | 不要 |
|
||||
| キーのリネーム (UI 変化なし) | 不要 |
|
||||
| キー削除 (画面から消える) | 必要 (`### Client` に Feat / 機能削除) |
|
||||
|
||||
書き方は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) を参照。
|
||||
|
||||
## トラブルシュート
|
||||
|
||||
i18n 周辺で踏みやすい失敗とその対処。エラー文字列で grep してたどり着けるよう整理。
|
||||
|
||||
@@ -88,8 +88,6 @@ git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'
|
||||
|
||||
**注意:** `grep -v 'ja-JP.yml'` を **diff 本文** に当てると ja-JP.yml 単体の変更でも `+追加行` が素通りして必ず非空になる。`--name-only` でファイル名だけに絞ってから完全一致で除外するのが正しい。
|
||||
|
||||
ユーザー影響のある UI 変更を伴う場合は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で CHANGELOG エントリの判定をする。
|
||||
|
||||
## 例: 「ノートを削除しますか?」確認ダイアログを追加する
|
||||
|
||||
1. `locales/ja-JP.yml`:
|
||||
|
||||
@@ -174,10 +174,6 @@ pnpm --filter frontend storybook-dev # localhost:6006
|
||||
pnpm --filter frontend test
|
||||
```
|
||||
|
||||
## CHANGELOG エントリ
|
||||
|
||||
ユーザーから見える変更 (新規コンポーネントが新しい UI として露出する、既存 UI の挙動を変える) なら、`CHANGELOG.md` に追記する。判定方法と書式は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で確認。
|
||||
|
||||
## 既存コンポーネントとの整合性
|
||||
|
||||
- 似た用途の既存 `Mk*` を 1-2 個読んで、props 命名 (`primary` / `danger` / `small` 等の形容詞、`onClose` ではなく `emit('close')` 等) を揃える
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
### コード・データ関連
|
||||
|
||||
- **SPDX ヘッダー必須**: AGPL-3.0-only 管轄かつ SPDX CI 対象ディレクトリに新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.scss` / `.vue` / `.html` ファイルを追加する場合は冒頭に必ず付ける。詳細な対象判定は `.github/workflows/check-spdx-license-id.yml` を参照。
|
||||
- **SPDX ヘッダー必須**: AGPL-3.0-only 管轄かつ SPDX CI 対象ディレクトリに新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.scss` / `.vue` / `.html` ファイルを追加する場合は冒頭に必ず付ける。
|
||||
対象判定は `scripts/check-spdx.mjs` を参照。
|
||||
|
||||
```text
|
||||
/*
|
||||
@@ -29,6 +30,8 @@
|
||||
```
|
||||
|
||||
`packages/misskey-js` は MIT ライセンスのサブパッケージなので、この AGPL ヘッダーを一律に付けない (サブパッケージ固有の `package.json` / `LICENSE` / 既存ファイルのヘッダーに従う)。
|
||||
SPDX の合否は CI と skill が同じ `scripts/check-spdx.mjs` で判定するため、code review で目視チェックを重ねない。
|
||||
CI は `--ci` で SPDX 行の有無を検査し、既定モードは加えて `.vue` / `.html` のコメント形式を検査する。
|
||||
|
||||
- **`locales/ja-JP.yml` 以外の locale YAML を編集しない**。他言語ファイル (`en-US.yml` など `ja-JP.yml` 以外すべて) は Crowdin の自動配信先で、手動編集すると次の同期で上書き喪失する。
|
||||
- **マージ済 migration を編集しない**。`packages/backend/migration/{timestamp}-*.js` のうち既に `develop` / `master` に入ったものは絶対に変更しない。スキーマ変更が必要なら新しい timestamp で新規ファイルを追加し、`up()` と `down()` の両方を実装する。
|
||||
@@ -49,17 +52,19 @@
|
||||
|
||||
## 変更を出す前の最低チェック
|
||||
|
||||
1. `pnpm lint` が通る (typecheck + eslint, 全パッケージ)
|
||||
1. ESLint 対象の変更ファイルへ package root から `eslint --quiet` を最後に 1 回実行し、実装変更には最も近い test を選んで実行する。
|
||||
package / repo 全体 lint と広域 test は任意
|
||||
2. backend で `meta` / `paramDef` / `res` を変更した → `pnpm build-misskey-js-with-types` を実行し `packages/misskey-js/src/autogen/` の差分も commit に含めた
|
||||
3. entity / migration を変更した → `pnpm --filter backend check-migrations` が pending DDL 0 件で通る / 新規 migration は `up()` と `down()` 両方実装済
|
||||
4. 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイルを追加した → SPDX ヘッダーを付けた
|
||||
5. ユーザー影響のある変更 → `CHANGELOG.md` の `## Unreleased` 配下の該当サブセクション (`### General` / `### Client` / `### Server`) に `- <Feat|Enhance|Fix>: <概要>` を 1 行追記
|
||||
6. `locales/` を編集した場合、`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空 (ja-JP.yml 以外に差分が無い) ことを確認
|
||||
4. `node scripts/check-spdx.mjs` が `SPDX: OK` を返した
|
||||
5. ユーザーが明示しない限り `CHANGELOG.md` を編集しない。
|
||||
ユーザー影響がある変更では引き継ぎに候補を 1 行だけ示す
|
||||
6. commit 済み・未commit・untracked の変更集合に `locales/ja-JP.yml` 以外の locale YAML が無いことを確認する
|
||||
|
||||
## Validation コマンド
|
||||
|
||||
- 全体ビルド: `pnpm build`
|
||||
- 全体 lint / typecheck: `pnpm lint`
|
||||
- 全体 lint / typecheck (任意): `pnpm lint`
|
||||
- Backend unit test: `pnpm --filter backend test`
|
||||
- Backend e2e test: `pnpm --filter backend test:e2e`
|
||||
- Backend federation test: `pnpm --filter backend test:fed`
|
||||
|
||||
@@ -14,67 +14,4 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7.0.0
|
||||
- name: Check
|
||||
run: |
|
||||
counter=0
|
||||
|
||||
search() {
|
||||
local directory="$1"
|
||||
find "$directory" -type f \
|
||||
'(' \
|
||||
-name "*.cjs" -and -not -name '*.config.cjs' -o \
|
||||
-name "*.html" -o \
|
||||
-name "*.js" -and -not -name '*.config.js' -o \
|
||||
-name "*.mjs" -and -not -name '*.config.mjs' -o \
|
||||
-name "*.scss" -o \
|
||||
-name "*.ts" -and -not -name '*.config.ts' -o \
|
||||
-name "*.vue" \
|
||||
')' -and \
|
||||
-not -name '*eslint*'
|
||||
}
|
||||
|
||||
check() {
|
||||
local file="$1"
|
||||
if ! (
|
||||
grep -q "SPDX-FileCopyrightText: syuilo and misskey-project" "$file" ||
|
||||
grep -q "SPDX-License-Identifier: AGPL-3.0-only" "$file"
|
||||
); then
|
||||
echo "Missing: $file"
|
||||
((counter++))
|
||||
fi
|
||||
}
|
||||
|
||||
directories=(
|
||||
"packages/backend/migration"
|
||||
"packages/backend/src"
|
||||
"packages/backend/test"
|
||||
"packages/frontend-shared/@types"
|
||||
"packages/frontend-shared/js"
|
||||
"packages/frontend-builder"
|
||||
"packages/frontend/.storybook"
|
||||
"packages/frontend/@types"
|
||||
"packages/frontend/lib"
|
||||
"packages/frontend/public"
|
||||
"packages/frontend/src"
|
||||
"packages/frontend/test"
|
||||
"packages/frontend-embed/@types"
|
||||
"packages/frontend-embed/src"
|
||||
"packages/icons-subsetter/src"
|
||||
"packages/misskey-bubble-game/src"
|
||||
"packages/misskey-reversi/src"
|
||||
"packages/sw/src"
|
||||
"scripts"
|
||||
)
|
||||
|
||||
for directory in "${directories[@]}"; do
|
||||
for file in $(search $directory); do
|
||||
check "$file"
|
||||
done
|
||||
done
|
||||
|
||||
if [ $counter -gt 0 ]; then
|
||||
echo "SPDX-License-Identifier is missing in $counter files."
|
||||
exit 1
|
||||
else
|
||||
echo "SPDX-License-Identifier is certainly described in all target files!"
|
||||
exit 0
|
||||
fi
|
||||
run: node scripts/check-spdx.mjs --ci
|
||||
|
||||
@@ -18,28 +18,11 @@
|
||||
|
||||
1. **SPDX ヘッダー欠落のまま AGPL 管轄ディレクトリへ新規ファイルを追加しない**
|
||||
- 対象: 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイル
|
||||
- CI の対象判定は [.github/workflows/check-spdx-license-id.yml](.github/workflows/check-spdx-license-id.yml) の `directories` 配列を参照 (`*.config.{ts,js,cjs,mjs}` と `*eslint*` は除外)
|
||||
- 欠落すると CI (`spdx` ジョブ) が失敗する
|
||||
- 対象と判定は [scripts/check-spdx.mjs](scripts/check-spdx.mjs) が一元管理する
|
||||
- `node scripts/check-spdx.mjs` を 1 回実行し、欠落は `--fix` で補う。
|
||||
`SPDX: OK` なら追加の目視確認はしない
|
||||
- `packages/misskey-js` は MIT ライセンスのサブパッケージなので、この AGPL ヘッダーを一律に付けない (サブパッケージ固有の `package.json` / `LICENSE` / 既存ファイルのヘッダーに従う)
|
||||
|
||||
`.ts` / `.js` / `.cjs` / `.mjs` / `.scss`:
|
||||
|
||||
```text
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
```
|
||||
|
||||
`.vue` / `.html` (HTML コメント形式):
|
||||
|
||||
```text
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
```
|
||||
|
||||
2. **`locales/ja-JP.yml` 以外の locale YAML を手動編集しない**
|
||||
- 他言語ファイル (`en-US.yml` など `ja-JP.yml` 以外すべて) は Crowdin の自動配信先。手動編集すると次の同期で上書き喪失する
|
||||
- 根拠: [locales/README.md](locales/README.md) と [crowdin.yml](crowdin.yml) (`ja-JP.yml` → `locales/%locale%.yml` の同期設定)
|
||||
@@ -53,7 +36,7 @@
|
||||
### Git / リポジトリ操作
|
||||
|
||||
4. **`git push --force` / `--force-with-lease` を `main` / `develop` / `master` にしない** (他人の作業を消す可能性)
|
||||
5. **`git commit --no-verify` で hook をスキップしない** (lint / format / SPDX チェックを潰す)
|
||||
5. **`git commit --no-verify` で hook をスキップしない**
|
||||
6. **マージ済 / プッシュ済コミットを `git commit --amend` で書き換えない** (履歴の整合性が壊れる)
|
||||
7. **他人のブランチを `git reset --hard` / `git branch -D` で破壊しない**
|
||||
8. **`git config` をユーザーに無断で書き換えない** (特に `user.name` / `user.email` / `commit.gpgsign`)
|
||||
@@ -80,12 +63,14 @@
|
||||
|
||||
各エージェントは [shipping-misskey-change スキル](.claude/skills/shipping-misskey-change/SKILL.md) を参照すること。スキルが利用できない環境でも、以下のチェックは必ず実施すること:
|
||||
|
||||
1. **lint**: `pnpm lint` が通る (typecheck + eslint, 全パッケージ)
|
||||
1. **lint / test**: ESLint 対象の変更ファイルへ package root から `eslint --quiet` を最後に 1 回実行し、実装変更には最も近い test を選んで実行する。
|
||||
package / repo 全体 lint と広域 test は任意
|
||||
2. **backend API 変更時**: `pnpm build-misskey-js-with-types` を実行し `packages/misskey-js/src/autogen/` の差分も commit に含めた
|
||||
3. **entity / migration 変更時**: `pnpm --filter backend check-migrations` が pending DDL 0 件で通る / 新規 migration は `up()` と `down()` 両方実装済
|
||||
4. **新規ファイル**: SPDX ヘッダーを付けた (`.vue` / `.html` は HTML コメント形式、それ以外は TS コメント形式)
|
||||
5. **ユーザー影響のある変更**: `CHANGELOG.md` の `## Unreleased` 配下の該当サブセクション (`### General` / `### Client` / `### Server`) に `- <Feat|Enhance|Fix>: <概要>` を 1 行追記
|
||||
6. **locale safety**: `locales/` を編集した場合、`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空 (ja-JP.yml 以外に差分が無い) ことを確認
|
||||
4. **SPDX**: `node scripts/check-spdx.mjs` が `SPDX: OK` を返すことを確認する
|
||||
5. **locale safety**: commit 済み・未commit・untracked の変更集合に `locales/ja-JP.yml` 以外の locale YAML が無いことを確認する
|
||||
6. **[CHANGELOG](.claude/skills/shipping-misskey-change/references/tasks/changelog-update.md)**: ユーザーが明示しない限り編集しない。
|
||||
ユーザー影響がある変更では引き継ぎに候補を 1 行だけ示す
|
||||
|
||||
### Validation commands
|
||||
|
||||
@@ -93,7 +78,7 @@
|
||||
|
||||
| 用途 | コマンド |
|
||||
| --- | --- |
|
||||
| 全体 lint (typecheck + eslint) | `pnpm lint` |
|
||||
| 全体 lint (任意) | `pnpm lint` |
|
||||
| Backend unit test | `pnpm --filter backend test` |
|
||||
| Backend e2e test | `pnpm --filter backend test:e2e` |
|
||||
| Backend federation test | `pnpm --filter backend test:fed` |
|
||||
|
||||
+5
-2
@@ -1,11 +1,14 @@
|
||||
## Unreleased
|
||||
## 2026.8.0
|
||||
|
||||
### General
|
||||
-
|
||||
- Enhance: ノート翻訳時、CWも翻訳対象に含めるように
|
||||
|
||||
### Client
|
||||
- Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます)
|
||||
- Enhance: 音声プレイヤーを画像・動画ビューワーに統合
|
||||
- Enhance: パフォーマンスの向上
|
||||
- Fix: 画像の表示時にBlurhashが描画されない場合があるのを修正
|
||||
- Fix: モバイルでの利用時に一部のテキストが選択できない問題を修正
|
||||
|
||||
### Server
|
||||
- Fix: 既にミュートしているスレッドに対して再度スレッドミュートを作成しようとするとサーバーエラーになる問題を修正
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "Afegeix al final"
|
||||
prepend: "Afegeix al principi"
|
||||
urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures"
|
||||
urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà."
|
||||
pixelatedZoom: "Mode de zoom d'art pixelat"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Títol de l'arxiu"
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "Añadir al final"
|
||||
prepend: "Añadir al principio"
|
||||
urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas"
|
||||
urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura."
|
||||
pixelatedZoom: "Modo de ampliación para pixel art"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Título del archivo"
|
||||
|
||||
+32
-3
@@ -174,7 +174,7 @@ emojiUrl: "پیوند شکلک"
|
||||
addEmoji: "افزودن شکلک"
|
||||
settingGuide: "تنظیمات پیشنهادی"
|
||||
cacheRemoteFiles: "کش کردن پرونده های خارجی"
|
||||
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش تصاویر را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
|
||||
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش پرونده ها را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
|
||||
youCanCleanRemoteFilesCache: "شما می توانید حافظه پنهان (کش) را با کلیک بر دکمه 🗑️ در پایین صفحه مدیریت پرونده ها حذف کنید."
|
||||
cacheRemoteSensitiveFiles: "کش کردن پرونده های حساس سرور های خارجی"
|
||||
cacheRemoteSensitiveFilesDescription: "با غیر فعال کردن این قابلیت پرونده هایی که علامت حساس خورده اند در سرور های خارجی به جای اینکه در حافظه پنهان ذخیره شوند، مستقیما به ان پیوند داده می شوند."
|
||||
@@ -200,23 +200,47 @@ removeWallpaper: "حذف تصویر پسزمینه"
|
||||
searchWith: "جستجو: {q}"
|
||||
youHaveNoLists: "شما فهرستی ندارید"
|
||||
followConfirm: "آیا می خواهید {name} را دنبال کنید؟"
|
||||
proxyAccount: "حساب وکیل"
|
||||
proxyAccount: "حساب نماینده (پروکسی)"
|
||||
host: "میزبان"
|
||||
selectSelf: "انتخاب خودت"
|
||||
selectUser: "انتخاب کاربر"
|
||||
recipient: "گیرنده"
|
||||
annotation: "حاشیه نویسی"
|
||||
federation: "شبکهاجتماعیغیرمتمرکز"
|
||||
instances: "سرور"
|
||||
registeredAt: "ثبت شده در"
|
||||
latestRequestReceivedAt: "آخرین درخواست دریافت شده"
|
||||
latestStatus: "جدیدترین وضعیت"
|
||||
storageUsage: "حافظه مصرفی"
|
||||
charts: "نمودارها"
|
||||
perHour: "در ساعت"
|
||||
perHour: "هر ۱ ساعت"
|
||||
perDay: "هر ۱ روز"
|
||||
stopActivityDelivery: "جلوگیری از ارائه فعالیت ها"
|
||||
blockThisInstance: "این سرور را مسدود کن"
|
||||
silenceThisInstance: "این سرور را بیصدا کن"
|
||||
mediaSilenceThisInstance: "محتوای چندرسانه ای این سرور را بیصدا کن"
|
||||
operations: "عملیات ها"
|
||||
software: "نرمافزار"
|
||||
softwareName: "نام نرمافزار"
|
||||
version: "نگارش"
|
||||
metadata: "متادیتا"
|
||||
withNFiles: "{n} پرونده"
|
||||
monitor: "رصدگر"
|
||||
jobQueue: "صفکار"
|
||||
cpuAndMemory: "پردازنده و حافظه رم"
|
||||
network: "شبکه"
|
||||
disk: "حافظه ذخیره"
|
||||
instanceInfo: "اطلاعات سرور"
|
||||
statistics: "آمار"
|
||||
clearQueue: "پاکسازی صف"
|
||||
clearQueueConfirmTitle: "آیا می خواهید این صف را پاک کنید؟"
|
||||
clearQueueConfirmText: "هر یادداشت تحویل داده نشده در شبکه اجتماعی غیرمتمرکز منتشر نخواهد شد. معمولا این عملیات لازم نیست."
|
||||
clearCachedFiles: "پاک کردن حافظه پنهان"
|
||||
clearCachedFilesConfirm: "آیا می خواهید که تمام پرونده هایی که از سرور های در حافظه پنهان دریافت کردید حذف شود."
|
||||
blockedInstances: "سرور های مسدود شده"
|
||||
blockedInstancesDescription: "نام سرور هایی که می خواهید مسدود کنید را فهرست کنید و با کلید اینتر (هر سرور در اول خط جدید) از هم جدا کنید. سرور های فهرست شده دیگه قابلیت ارتباط گیری با این سرور را ندارند."
|
||||
silencedInstances: "سرور های بیصدا شده"
|
||||
silencedInstancesDescription: "نام سرور هایی که میخواهید بیصدا کنید را فهرست کنید و با کلید اینتر (هر سرور در اول خط جدید) از هم جدا کنید. تمام حساب ها از سرور های لیست شده \"بیصدا\" در نظر گرفته می شود و همهی دنبال کردن ها به کاربر های این سرور به درخواست دنبال کردن تبدیل میشود. این قابلیت بر روی سرور های مسدود شده تاثیری نخواهد داشت."
|
||||
noCustomEmojis: "هیچ شکلکی نیست"
|
||||
remove: "حذف"
|
||||
pinnedNotes: "یادداشت سنجاق شده"
|
||||
@@ -227,6 +251,7 @@ smtpHost: "میزبان"
|
||||
smtpUser: "نام کاربری"
|
||||
smtpPass: "گذرواژه"
|
||||
theKeywordWhenSearchingForCustomEmoji: "این کلمه کلیدی است که هنگام جستجو در میان شکلک های سفارشی به کار گرفته می شود."
|
||||
clearCache: "پاک کردن حافظه پنهان"
|
||||
user: "کاربران"
|
||||
searchByGoogle: "جستجو"
|
||||
file: "پرونده ها"
|
||||
@@ -256,6 +281,8 @@ _widgets:
|
||||
instanceInfo: "اطلاعات سرور"
|
||||
notifications: "اعلان ها"
|
||||
timeline: "نوار زمانی"
|
||||
federation: "شبکهاجتماعیغیرمتمرکز"
|
||||
jobQueue: "صفکار"
|
||||
_userList:
|
||||
chooseList: "انتخاب یک فهرست"
|
||||
_cw:
|
||||
@@ -270,6 +297,8 @@ _exportOrImport:
|
||||
muteList: "سکوت"
|
||||
blockingList: "مسدود کردن"
|
||||
userLists: "فهرست ها"
|
||||
_charts:
|
||||
federation: "شبکهاجتماعیغیرمتمرکز"
|
||||
_notification:
|
||||
youWereFollowed: "شما دنبال شده اید"
|
||||
_types:
|
||||
|
||||
@@ -1040,6 +1040,7 @@ thisPostMayBeAnnoyingHome: "Publier vers le fil principal"
|
||||
thisPostMayBeAnnoyingCancel: "Annuler"
|
||||
thisPostMayBeAnnoyingIgnore: "Publier quand-même"
|
||||
collapseRenotes: "Réduire les renotes déjà vues"
|
||||
collapseRenotesDescription: "Réduire les notes que vous avez renoté ou déjà réagi."
|
||||
internalServerError: "Erreur interne du serveur"
|
||||
internalServerErrorDescription: "Une erreur inattendue s'est produite sur le serveur."
|
||||
copyErrorInfo: "Copier les détails de l’erreur"
|
||||
@@ -1283,6 +1284,7 @@ postForm: "Formulaire de publication"
|
||||
information: "Informations"
|
||||
inMinutes: "min"
|
||||
inDays: "j"
|
||||
createUserSpecifiedNote: "Créer une note directe"
|
||||
widgets: "Widgets"
|
||||
presets: "Préréglage"
|
||||
previewingThemeRestore: "Restaurer"
|
||||
@@ -1758,6 +1760,8 @@ _aboutMisskey:
|
||||
patrons: "Contributeurs"
|
||||
projectMembers: "Membres du projet"
|
||||
_displayOfSensitiveMedia:
|
||||
respect: "Cacher les médias marqués comme sensibles"
|
||||
ignore: "Afficher les médias marqués comme sensibles"
|
||||
force: "Masquer tous les médias"
|
||||
_instanceTicker:
|
||||
none: "Cacher "
|
||||
|
||||
@@ -1040,6 +1040,7 @@ caption: "キャプション"
|
||||
loggedInAsBot: "Botアカウントでログイン中"
|
||||
tools: "ツール"
|
||||
cannotLoad: "読み込めません"
|
||||
cannotPreview: "プレビューできません"
|
||||
numberOfProfileView: "プロフィール表示回数"
|
||||
like: "いいね!"
|
||||
unlike: "いいねを解除"
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "加到最后"
|
||||
prepend: "加到最前"
|
||||
urlPreviewSensitiveList: "限制显示缩略图的 URL"
|
||||
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
|
||||
pixelatedZoom: "像素画放大模式"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "文件标题"
|
||||
|
||||
@@ -1421,6 +1421,7 @@ append: "加在最後"
|
||||
prepend: "加在前面"
|
||||
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
|
||||
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
|
||||
pixelatedZoom: "像素藝術縮放模式"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "檔案標題"
|
||||
|
||||
+12
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.7.0",
|
||||
"version": "2026.8.0-alpha.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@11.11.0",
|
||||
"packageManager": "pnpm@11.21.0",
|
||||
"workspaces": [
|
||||
"packages/backend",
|
||||
"packages/frontend-shared",
|
||||
@@ -54,25 +54,25 @@
|
||||
"cleanall": "pnpm clean-all"
|
||||
},
|
||||
"dependencies": {
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"esbuild": "0.28.2",
|
||||
"execa": "10.0.1",
|
||||
"ignore-walk": "9.0.0",
|
||||
"js-yaml": "5.2.2",
|
||||
"tar": "7.5.21"
|
||||
"js-yaml": "5.2.3",
|
||||
"tar": "7.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.5",
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "9.39.5",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.9.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "11.11.0",
|
||||
"start-server-and-test": "3.0.11",
|
||||
"pnpm": "11.21.0",
|
||||
"start-server-and-test": "3.0.12",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mdast": "4.0.4",
|
||||
"@types/node": "26.1.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"mdast-util-to-string": "4.0.0",
|
||||
"remark": "15.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"tsx": "4.23.1",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
"devDependencies": {
|
||||
"@types/mdast": "4.0.4",
|
||||
"@types/node": "26.2.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"mdast-util-to-string": "4.0.0",
|
||||
"remark": "15.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"tsx": "4.23.12",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"diagnostics-shared": "workspace:*",
|
||||
"execa": "9.6.1"
|
||||
"execa": "10.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"ioredis": "5.11.1",
|
||||
"pg": "8.22.0",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/pg": "8.21.0",
|
||||
"ioredis": "6.0.0",
|
||||
"pg": "8.23.0",
|
||||
"tsx": "4.23.12",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
"diagnostics-shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"frontend": "workspace:*",
|
||||
"playwright": "1.61.1",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "5.9.3",
|
||||
"playwright": "1.62.1",
|
||||
"tsx": "4.23.12",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "26.2.0",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,79 +51,79 @@
|
||||
"utf-8-validate": "6.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1085.0",
|
||||
"@aws-sdk/lib-storage": "3.1085.0",
|
||||
"@aws-sdk/client-s3": "3.1107.0",
|
||||
"@aws-sdk/lib-storage": "3.1107.0",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.3.0",
|
||||
"@fastify/http-proxy": "11.5.0",
|
||||
"@fastify/http-proxy": "11.6.0",
|
||||
"@fastify/multipart": "10.1.0",
|
||||
"@fastify/static": "10.1.2",
|
||||
"@fastify/static": "10.1.3",
|
||||
"@kitajs/html": "4.2.13",
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@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.2",
|
||||
"@nestjs/common": "11.1.28",
|
||||
"@nestjs/core": "11.1.28",
|
||||
"@nestjs/testing": "11.1.28",
|
||||
"@oxc-project/runtime": "0.139.0",
|
||||
"@napi-rs/canvas": "1.0.5",
|
||||
"@nestjs/common": "11.1.29",
|
||||
"@nestjs/core": "11.1.29",
|
||||
"@nestjs/testing": "11.1.29",
|
||||
"@oxc-project/runtime": "0.144.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.65.0",
|
||||
"@sentry/profiling-node": "10.65.0",
|
||||
"@sentry/node": "10.70.0",
|
||||
"@sentry/profiling-node": "10.70.0",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@smithy/node-http-handler": "4.9.5",
|
||||
"@smithy/node-http-handler": "4.9.13",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.20.0",
|
||||
"archiver": "8.0.0",
|
||||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bullmq": "5.80.2",
|
||||
"bullmq": "6.0.11",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"chalk": "5.6.2",
|
||||
"chalk": "6.0.0",
|
||||
"chalk-template": "1.1.2",
|
||||
"chokidar": "5.0.0",
|
||||
"color-convert": "3.1.3",
|
||||
"content-disposition": "2.0.1",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-email-validator": "0.1.27",
|
||||
"fastify": "5.10.0",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"fastify": "5.11.3",
|
||||
"fastify-raw-body": "6.0.1",
|
||||
"feed": "6.0.0",
|
||||
"file-type": "22.0.1",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"got": "15.1.0",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "1.1.3",
|
||||
"http-link-header": "1.1.4",
|
||||
"i18n": "workspace:*",
|
||||
"ioredis": "5.11.1",
|
||||
"ip-cidr": "4.0.2",
|
||||
"ipaddr.js": "2.4.0",
|
||||
"ipaddr.js": "2.5.0",
|
||||
"is-svg": "6.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "9.0.0",
|
||||
"juice": "12.1.1",
|
||||
"meilisearch": "0.59.0",
|
||||
"juice": "12.1.2",
|
||||
"meilisearch": "0.60.0",
|
||||
"mfm-js": "0.26.0",
|
||||
"mime-types": "3.0.2",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"ms": "3.0.0-canary.202508261828",
|
||||
"nanoid": "6.0.0",
|
||||
"nanoid": "6.0.1",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "9.0.0",
|
||||
"nodemailer": "9.0.3",
|
||||
"node-html-parser": "9.0.1",
|
||||
"nodemailer": "9.0.5",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.5.1",
|
||||
"pg": "8.22.0",
|
||||
"pg": "8.23.0",
|
||||
"pkce-challenge": "6.0.0",
|
||||
"probe-image-size": "7.3.0",
|
||||
"promise-limit": "2.7.0",
|
||||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.26.0",
|
||||
"re2": "1.26.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
@@ -134,22 +134,22 @@
|
||||
"slacc": "0.1.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.31.16",
|
||||
"systeminformation": "5.33.1",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.7",
|
||||
"tsc-alias": "1.9.0",
|
||||
"tsc-alias": "1.9.1",
|
||||
"typeorm": "1.1.0",
|
||||
"ulid": "3.0.2",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.21.0",
|
||||
"ws": "8.21.3",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kitajs/ts-html-plugin": "4.1.4",
|
||||
"@nestjs/platform-express": "11.1.28",
|
||||
"@nestjs/platform-express": "11.1.29",
|
||||
"@rollup/plugin-esm-shim": "0.1.8",
|
||||
"@sentry/vue": "10.65.0",
|
||||
"@sentry/vue": "10.70.0",
|
||||
"@sinonjs/fake-timers": "15.4.0",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "8.0.0",
|
||||
@@ -158,15 +158,15 @@
|
||||
"@types/jsonld": "1.5.15",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/pg": "8.21.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
"@types/rename": "1.0.7",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/simple-oauth2": "5.0.8",
|
||||
"@types/sinonjs__fake-timers": "15.0.1",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
@@ -174,21 +174,21 @@
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cbor2": "2.3.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "9.6.1",
|
||||
"execa": "10.0.1",
|
||||
"fkill": "10.0.3",
|
||||
"js-yaml": "5.2.2",
|
||||
"js-yaml": "5.2.3",
|
||||
"pid-port": "2.1.1",
|
||||
"rolldown": "1.1.5",
|
||||
"rolldown": "1.2.3",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"vite": "8.1.4",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "4.1.10",
|
||||
"vitest-mock-extended": "4.0.0"
|
||||
"vitest-mock-extended": "5.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import { dirname, resolve } from 'node:path';
|
||||
import { type FastifyServerOptions } from 'fastify';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import type { RedisOptions as IoRedisRedisOptions } from 'ioredis';
|
||||
import type { RedisOptions as BullMqRedisOptions } from 'bullmq';
|
||||
import type { AccessLogConfiguration, LogFormat, LogLevelSetting } from './logging/types.js';
|
||||
|
||||
type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
type RedisOptionsRequiredFields = {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
@@ -20,6 +21,8 @@ type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
type RedisOptionsSource = Partial<IoRedisRedisOptions & BullMqRedisOptions> & RedisOptionsRequiredFields;
|
||||
type RedisOptionsResolved = IoRedisRedisOptions & BullMqRedisOptions & RedisOptionsRequiredFields;
|
||||
|
||||
type SentryBackendConfig = {
|
||||
options: Partial<Sentry.NodeOptions>;
|
||||
@@ -211,11 +214,11 @@ export type Config = {
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
videoThumbnailGenerator: string | null;
|
||||
redis: RedisOptions & RedisOptionsSource;
|
||||
redisForPubsub: RedisOptions & RedisOptionsSource;
|
||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||
redisForReactions: RedisOptions & RedisOptionsSource;
|
||||
redis: RedisOptionsResolved;
|
||||
redisForPubsub: RedisOptionsResolved;
|
||||
redisForJobQueue: RedisOptionsResolved;
|
||||
redisForTimelines: RedisOptionsResolved;
|
||||
redisForReactions: RedisOptionsResolved;
|
||||
sentryForBackend: SentryBackendConfig | undefined;
|
||||
sentryForFrontend: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
@@ -366,7 +369,7 @@ function tryCreateUrl(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource {
|
||||
function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptionsResolved {
|
||||
return {
|
||||
...options,
|
||||
password: options.pass,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { FlashService } from '@/core/FlashService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
import { SensitiveMediaDetectionService } from './SensitiveMediaDetectionService.js';
|
||||
import { AnnouncementService } from './AnnouncementService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
import { AchievementService } from './AchievementService.js';
|
||||
@@ -164,7 +164,7 @@ const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisti
|
||||
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
|
||||
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
|
||||
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
|
||||
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
|
||||
const $SensitiveMediaDetectionService: Provider = { provide: 'SensitiveMediaDetectionService', useExisting: SensitiveMediaDetectionService };
|
||||
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
|
||||
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
|
||||
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
|
||||
@@ -317,7 +317,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
AbuseReportNotificationService,
|
||||
AccountMoveService,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
SensitiveMediaDetectionService,
|
||||
AnnouncementService,
|
||||
AntennaService,
|
||||
AchievementService,
|
||||
@@ -468,7 +468,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$AbuseReportNotificationService,
|
||||
$AccountMoveService,
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$SensitiveMediaDetectionService,
|
||||
$AnnouncementService,
|
||||
$AntennaService,
|
||||
$AchievementService,
|
||||
@@ -619,7 +619,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
AbuseReportNotificationService,
|
||||
AccountMoveService,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
SensitiveMediaDetectionService,
|
||||
AnnouncementService,
|
||||
AntennaService,
|
||||
AchievementService,
|
||||
@@ -769,7 +769,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$AbuseReportNotificationService,
|
||||
$AccountMoveService,
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$SensitiveMediaDetectionService,
|
||||
$AnnouncementService,
|
||||
$AntennaService,
|
||||
$AchievementService,
|
||||
|
||||
@@ -16,12 +16,12 @@ import probeImageSize from 'probe-image-size';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import * as blurhash from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import type { Prediction } from '@/core/AiService.js';
|
||||
import type { Prediction } from '@/core/SensitiveMediaDetectionService.js';
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
@@ -54,7 +54,7 @@ export class FileInfoService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
private aiService: AiService,
|
||||
private sensitiveMediaDetectionService: SensitiveMediaDetectionService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('file-info');
|
||||
@@ -266,7 +266,7 @@ export class FileInfoService {
|
||||
fs.promises.unlink(path);
|
||||
}
|
||||
}
|
||||
const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
|
||||
const predictions = await this.sensitiveMediaDetectionService.detectSensitiveMany(frameBuffers);
|
||||
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
|
||||
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
|
||||
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
|
||||
@@ -291,7 +291,7 @@ export class FileInfoService {
|
||||
.flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす
|
||||
.png()
|
||||
.toBuffer();
|
||||
const result = await this.aiService.detectSensitive(png);
|
||||
const result = await this.sensitiveMediaDetectionService.detectSensitive(png);
|
||||
if (result) {
|
||||
[sensitive, porn] = judgePrediction(result);
|
||||
}
|
||||
|
||||
@@ -901,7 +901,7 @@ export class QueueService {
|
||||
const isPaused = await queue.isPaused();
|
||||
const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
|
||||
const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
|
||||
const db = parseRedisInfo(await (await queue.client).info());
|
||||
const db = parseRedisInfo(await (await queue.getBackend().client).info());
|
||||
|
||||
return {
|
||||
name: queueType,
|
||||
|
||||
+2
-2
@@ -69,7 +69,7 @@ function isDetectImagesResponse(v: unknown): v is DetectImagesResponse {
|
||||
const DETECT_IMAGES_PATH = 'v1/detect-images';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
export class SensitiveMediaDetectionService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@@ -79,7 +79,7 @@ export class AiService {
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('ai');
|
||||
this.logger = this.loggerService.getLogger('sensitive-media-detection');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,24 +132,29 @@ function normalizeString(value: string, maxBytes: number): string {
|
||||
return value.slice(0, end) + suffix;
|
||||
}
|
||||
|
||||
/** 指定した後置文字列を含めて上限に収まる接頭辞の長さを二分探索します。 */
|
||||
function findMaxPrefixLength(value: string, suffix: string, maxBytes: number): number {
|
||||
let lower = 0;
|
||||
let upper = value.length;
|
||||
while (lower < upper) {
|
||||
const middle = Math.ceil((lower + upper) / 2);
|
||||
if (byteLength(value.slice(0, middle) + suffix) <= maxBytes) {
|
||||
lower = middle;
|
||||
} else {
|
||||
upper = middle - 1;
|
||||
}
|
||||
function charUtf8Len(codePoint: number): number {
|
||||
return codePoint < 0x80 ? 1
|
||||
: codePoint < 0x800 ? 2
|
||||
: codePoint < 0x10000 ? 3
|
||||
: 4;
|
||||
}
|
||||
|
||||
function charUtf16Len(codePoint: number): number {
|
||||
return codePoint < 0x10000 ? 1 : 2;
|
||||
}
|
||||
|
||||
function findMaxPrefixLength(value: string, suffix: string, maxBytes: number) {
|
||||
let usedBytes = byteLength(suffix);
|
||||
let prefixLength = 0;
|
||||
while (prefixLength < value.length) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- similar to for-of, but for-of requires additional allocations
|
||||
const cp = value.codePointAt(prefixLength)!;
|
||||
const charBytes = charUtf8Len(cp);
|
||||
if (usedBytes + charBytes > maxBytes) break;
|
||||
usedBytes += charBytes;
|
||||
prefixLength += charUtf16Len(cp);
|
||||
}
|
||||
// UTF-16のサロゲート対を途中で切らないよう、必要なら1文字戻します。
|
||||
if (lower > 0 && lower < value.length) {
|
||||
const code = value.charCodeAt(lower - 1);
|
||||
if (code >= 0xd800 && code <= 0xdbff) lower--;
|
||||
}
|
||||
return lower;
|
||||
return prefixLength;
|
||||
}
|
||||
|
||||
/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */
|
||||
|
||||
@@ -28,7 +28,7 @@ export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
queue: { type: 'string', enum: QUEUE_TYPES },
|
||||
state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed', 'paused'] } },
|
||||
state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } },
|
||||
search: { type: 'string' },
|
||||
},
|
||||
required: ['queue', 'state'],
|
||||
|
||||
@@ -12,7 +12,7 @@ export const meta = {
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:emoji',
|
||||
kind: 'read:admin:queue',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
|
||||
@@ -10,9 +10,9 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -83,7 +83,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
|
||||
}
|
||||
|
||||
if (note.text == null) {
|
||||
let text = note.text ?? '';
|
||||
if (note.cw != null) {
|
||||
text = `${note.cw}\n-----\n${text}`;
|
||||
}
|
||||
|
||||
if (text.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,7 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('text', note.text);
|
||||
params.append('text', text);
|
||||
params.append('target_lang', targetLang);
|
||||
|
||||
const endpoint = this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, beforeAll } from 'vitest';
|
||||
import { describe, test, beforeAll, vi } from 'vitest';
|
||||
import { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js';
|
||||
import { createAccount, createModerator, resolveRemoteUser, type LoginUser, WAIT_FOR_FEDERATION } from './utils.js';
|
||||
|
||||
describe('Abuse report', () => {
|
||||
describe('Forwarding report', () => {
|
||||
@@ -31,10 +31,14 @@ describe('Abuse report', () => {
|
||||
const reports = await aModerator.client.request('admin/abuse-user-reports', {});
|
||||
const report = reports.filter(report => report.comment === comment)[0];
|
||||
await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id });
|
||||
await sleep();
|
||||
|
||||
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
|
||||
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
|
||||
const reportInB = await vi.waitFor(async () => {
|
||||
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
|
||||
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
strictEqual(reportInB != null, true);
|
||||
return reportInB;
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
// NOTE: reporter is not Alice, and is not moderator in A
|
||||
strictEqual(reportInB.reporter.url, 'https://a.test/@system.actor');
|
||||
strictEqual(reportInB.targetUserId, bob.id);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, test, beforeAll } from 'vitest';
|
||||
import { describe, test, beforeAll, vi } from 'vitest';
|
||||
import { deepStrictEqual, rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, WAIT_FOR_FEDERATION } from './utils.js';
|
||||
|
||||
describe('Block', () => {
|
||||
async function waitForBlocked(blocked: LoginUser, blockerId: string): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
const blocker = await blocked.client.request('users/show', { userId: blockerId });
|
||||
strictEqual(blocker.isBlocked, true);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
}
|
||||
|
||||
describe('Check follow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
@@ -22,7 +29,7 @@ describe('Block', () => {
|
||||
|
||||
test('Cannot follow if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForBlocked(bob, aliceInB.id);
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
@@ -104,7 +111,7 @@ describe('Block', () => {
|
||||
|
||||
test('Cannot reply if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForBlocked(bob, aliceInB.id);
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
@@ -147,7 +154,7 @@ describe('Block', () => {
|
||||
|
||||
test('Cannot reaction if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForBlocked(bob, aliceInB.id);
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
@@ -211,7 +218,7 @@ describe('Block', () => {
|
||||
/** NOTE: You should mute the target to stop receiving notifications */
|
||||
test('Can mention and notified even if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForBlocked(bob, aliceInB.id);
|
||||
|
||||
const text = `@${alice.username}@a.test plz unblock me!`;
|
||||
await assertNotificationReceived(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, beforeAll } from 'vitest';
|
||||
import { describe, test, beforeAll, vi } from 'vitest';
|
||||
import assert, { strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile, waitForFollowRelation, WAIT_FOR_FEDERATION } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
@@ -106,18 +106,19 @@ describe('Drive', () => {
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRelation(bob, alice, 1);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
strictEqual(notes.length, 1);
|
||||
const noteInB = notes[0];
|
||||
const noteInB = await vi.waitFor(async () => {
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
strictEqual(notes.length, 1);
|
||||
return notes[0];
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, beforeAll } from 'vitest';
|
||||
import { describe, test, beforeAll, vi } from 'vitest';
|
||||
import assert, { deepStrictEqual, strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js';
|
||||
import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, waitForFollowRelation, WAIT_FOR_FEDERATION } from './utils.js';
|
||||
|
||||
describe('Emoji', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
@@ -19,18 +19,24 @@ describe('Emoji', () => {
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRelation(bob, alice, 1);
|
||||
});
|
||||
|
||||
async function waitForNoteInB(cond: (note: Misskey.entities.Note) => boolean): Promise<Misskey.entities.Note> {
|
||||
return await vi.waitFor(async () => {
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes.at(0);
|
||||
strictEqual(noteInB != null && cond(noteInB), true);
|
||||
return noteInB!;
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
}
|
||||
|
||||
test('Custom emoji are delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
const noteInB = await waitForNoteInB(note => note.text === `I love \u200b:${emoji.name}:\u200b`);
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
assert(noteInB.emojis != null);
|
||||
assert(emoji.name in noteInB.emojis);
|
||||
strictEqual(noteInB.emojis[emoji.name], emoji.url);
|
||||
@@ -39,23 +45,23 @@ describe('Emoji', () => {
|
||||
test('Custom emoji are delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
await waitForNoteInB(noteInB => noteInB.uri === `https://a.test/notes/${note.id}`);
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1);
|
||||
const noteInB = await waitForNoteInB(noteInB => noteInB.reactions[`:${emoji.name}@a.test:`] === 1);
|
||||
deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
const renewedaliceInB = await vi.waitFor(async () => {
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
return renewedaliceInB;
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
assert(emoji.name in renewedaliceInB.emojis);
|
||||
strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
@@ -63,12 +69,9 @@ describe('Emoji', () => {
|
||||
test('Local-only custom emoji aren\'t delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
const noteInB = await waitForNoteInB(note => note.text === `I love \u200b:${emoji.name}:\u200b`);
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
// deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?)
|
||||
deepStrictEqual({ ...noteInB.emojis }, {});
|
||||
});
|
||||
@@ -76,12 +79,11 @@ describe('Emoji', () => {
|
||||
test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
await waitForNoteInB(noteInB => noteInB.uri === `https://a.test/notes/${note.id}`);
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
const noteInB = await waitForNoteInB(noteInB => noteInB.reactions['❤'] === 1);
|
||||
deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 });
|
||||
deepStrictEqual({ ...noteInB.reactionEmojis }, {});
|
||||
});
|
||||
@@ -89,10 +91,12 @@ describe('Emoji', () => {
|
||||
test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
const renewedaliceInB = await vi.waitFor(async () => {
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
return renewedaliceInB;
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
deepStrictEqual({ ...renewedaliceInB.emojis }, {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, beforeAll } from 'vitest';
|
||||
import assert, { strictEqual } from 'node:assert';
|
||||
import { createAccount, type LoginUser, sleep } from './utils.js';
|
||||
import { createAccount, type LoginUser, waitForFollowers, waitForFollowing } from './utils.js';
|
||||
|
||||
describe('Move', () => {
|
||||
test('Minimum move', async () => {
|
||||
@@ -30,7 +30,9 @@ describe('Move', () => {
|
||||
// Move @alice@a.test ==> @bob@b.test
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
await sleep();
|
||||
|
||||
await waitForFollowing(carol, 2);
|
||||
await waitForFollowers(bob, 1);
|
||||
});
|
||||
|
||||
test('Check from follower', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, test, beforeAll, afterAll, vi } from 'vitest';
|
||||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile, waitForFollowRelation, WAIT_FOR_FEDERATION } from './utils.js';
|
||||
|
||||
describe('Note', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
@@ -85,10 +85,10 @@ describe('Note', () => {
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
|
||||
await sleep();
|
||||
|
||||
const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId });
|
||||
strictEqual(resolvedReplyedNote.repliesCount, 1);
|
||||
await vi.waitFor(async () => {
|
||||
const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId! });
|
||||
strictEqual(resolvedReplyedNote.repliesCount, 1);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
});
|
||||
|
||||
test('Consistency of Renote', async () => {
|
||||
@@ -146,27 +146,26 @@ describe('Note', () => {
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
await carol.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForFollowRelation(carol, bob, 1);
|
||||
});
|
||||
|
||||
test('Check', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
await vi.waitFor(async () => await rejects(
|
||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
), WAIT_FOR_FEDERATION);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await carol.client.request('following/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForFollowRelation(carol, bob, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,18 +174,20 @@ describe('Note', () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, alice);
|
||||
await alice.client.request('notes/create', { renoteId: noteInA.id });
|
||||
await sleep();
|
||||
await vi.waitFor(async () => {
|
||||
const renotes = await bob.client.request('notes/renotes', { noteId: note.id });
|
||||
strictEqual(renotes.length, 1);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
await vi.waitFor(async () => await rejects(
|
||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
), WAIT_FOR_FEDERATION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,18 +196,20 @@ describe('Note', () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, alice);
|
||||
await alice.client.request('notes/create', { text: 'Hello Bob!', replyId: noteInA.id });
|
||||
await sleep();
|
||||
await vi.waitFor(async () => {
|
||||
const replies = await bob.client.request('notes/replies', { noteId: note.id });
|
||||
strictEqual(replies.length, 1);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
await vi.waitFor(async () => await rejects(
|
||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
), WAIT_FOR_FEDERATION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,16 +272,22 @@ describe('Note', () => {
|
||||
});
|
||||
|
||||
describe('Reaction', () => {
|
||||
async function waitForReactions(noteId: string, count: number): Promise<Misskey.entities.NotesReactionsResponse> {
|
||||
return await vi.waitFor(async () => {
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId });
|
||||
strictEqual(reactions.length, count);
|
||||
return reactions;
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
}
|
||||
|
||||
describe('Consistency', () => {
|
||||
test('Unicode reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
const reactions = await waitForReactions(note.id, 1);
|
||||
strictEqual(reactions[0].type, reaction);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
@@ -288,10 +297,8 @@ describe('Note', () => {
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
const reactions = await waitForReactions(note.id, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
@@ -303,10 +310,8 @@ describe('Note', () => {
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
const reactions = await waitForReactions(note.id, 1);
|
||||
strictEqual(reactions[0].type, '❤');
|
||||
});
|
||||
|
||||
@@ -319,10 +324,8 @@ describe('Note', () => {
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test', { isSensitive: true });
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
const reactions = await waitForReactions(note.id, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
});
|
||||
});
|
||||
@@ -340,12 +343,13 @@ describe('Note', () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
await vi.waitFor(async () => {
|
||||
const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,7 +366,7 @@ describe('Note', () => {
|
||||
]);
|
||||
|
||||
await bobRemoteFollower.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForFollowRelation(bobRemoteFollower, bob, 1);
|
||||
});
|
||||
|
||||
test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => {
|
||||
@@ -370,12 +374,13 @@ describe('Note', () => {
|
||||
// NOTE: resolve before voting
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower);
|
||||
await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
await vi.waitFor(async () => {
|
||||
const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, beforeAll, afterAll } from 'vitest';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, waitForFollowing, waitForFollowRelation } from './utils.js';
|
||||
|
||||
describe('Notification', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
@@ -27,8 +27,12 @@ describe('Notification', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
// NOTE: 次のテストで再度フォローする。a.test が Undo Follow を処理する前に次の Follow が
|
||||
// 届くと「すでにフォロー関係が存在」扱いで Accept だけ返されて follow 通知が発生せず、
|
||||
// Undo が後勝ちした場合はその Accept すら返らなくなる。
|
||||
// b.test 側の followings は同期的に消えるので、a.test への反映まで待つ必要がある
|
||||
await bob.client.request('following/delete', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRelation(bob, alice, 0);
|
||||
});
|
||||
|
||||
test('Get notification when get followed', async () => {
|
||||
@@ -40,7 +44,12 @@ describe('Notification', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id }));
|
||||
afterAll(async () => {
|
||||
// NOTE: follow 通知は a.test 側の処理で発生するため、この時点で b.test が Accept を
|
||||
// 受け取っているとは限らない。そのまま解除すると NOT_FOLLOWING になる
|
||||
await waitForFollowing(bob, 1);
|
||||
await bob.client.request('following/delete', { userId: aliceInB.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, test, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js';
|
||||
import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole, waitForFollowRelation, FEDERATION_TIMEOUT, WAIT_FOR_FEDERATION } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
@@ -21,7 +21,9 @@ describe('Timeline', () => {
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
// NOTE: 配送側 (a.test) だけでなく、受信側のホームタイムラインへの fan-out にも
|
||||
// followings が要るので、両サーバーへ反映されるまで待つ
|
||||
await waitForFollowRelation(bob, alice, 1);
|
||||
});
|
||||
|
||||
type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
|
||||
@@ -52,6 +54,7 @@ describe('Timeline', () => {
|
||||
},
|
||||
'note', msg => msg.text === text,
|
||||
channelParams,
|
||||
expect ? FEDERATION_TIMEOUT : undefined,
|
||||
);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
@@ -63,9 +66,19 @@ describe('Timeline', () => {
|
||||
endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } :
|
||||
{};
|
||||
|
||||
const noteUri = `https://a.test/notes/${note!.id}`;
|
||||
const fetchNoteInB = async () => (await (bob.client.request as Request)(endpoint, params))
|
||||
.filter(({ uri }) => uri === noteUri).pop();
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop();
|
||||
// 届かないはずのケースは待っても意味がないので 1 回だけ取得する
|
||||
const noteInB = expect
|
||||
? await vi.waitFor(async () => {
|
||||
const noteInB = await fetchNoteInB();
|
||||
strictEqual(noteInB != null, true);
|
||||
return noteInB!;
|
||||
}, WAIT_FOR_FEDERATION)
|
||||
: await fetchNoteInB();
|
||||
const endpointFired = noteInB != null;
|
||||
strictEqual(endpointFired, expect);
|
||||
|
||||
@@ -75,13 +88,14 @@ describe('Timeline', () => {
|
||||
'b.test', bob, noteInB!.id,
|
||||
async () => await alice.client.request('notes/delete', { noteId: note!.id }),
|
||||
msg => msg.type === 'deleted' && msg.id === noteInB!.id,
|
||||
FEDERATION_TIMEOUT,
|
||||
);
|
||||
strictEqual(streamingFired, true);
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`);
|
||||
strictEqual(endpointFired, true);
|
||||
await vi.waitFor(async () => {
|
||||
const endpointFired = await fetchNoteInB() == null;
|
||||
strictEqual(endpointFired, true);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, beforeAll } from 'vitest';
|
||||
import { describe, test, beforeAll, vi } from 'vitest';
|
||||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, waitForFollowers, waitForFollowing, waitForFollowRelation, WAIT_FOR_FEDERATION, WAIT_FOR_SLOW_FEDERATION } from './utils.js';
|
||||
|
||||
const [aAdmin, bAdmin] = await Promise.all([
|
||||
fetchAdmin('a.test'),
|
||||
@@ -70,7 +70,10 @@ describe('User', () => {
|
||||
alice.client.request('following/create', { userId: bobInA.id }),
|
||||
bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
]);
|
||||
await sleep();
|
||||
await Promise.all([
|
||||
waitForFollowRelation(alice, bob, 1),
|
||||
waitForFollowRelation(bob, alice, 1),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Visibility set public by default', async () => {
|
||||
@@ -139,13 +142,16 @@ describe('User', () => {
|
||||
|
||||
test('Becoming a cat is sent to their followers', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
// Update は「配送時点のフォロワー」にしか配送されないので、
|
||||
// フォローが a.test 側に反映されてから i/update する
|
||||
await waitForFollowRelation(bob, alice, 1);
|
||||
|
||||
await alice.client.request('i/update', { isCat: true });
|
||||
await sleep();
|
||||
|
||||
const res = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(res.isCat, true);
|
||||
await vi.waitFor(async () => {
|
||||
const res = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(res.isCat, true);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,6 +167,7 @@ describe('User', () => {
|
||||
aliceInB = await resolveRemoteUser('a.test', alice.id, bob);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await waitForFollowRelation(bob, alice, 1);
|
||||
});
|
||||
|
||||
test('Pinning localOnly Note is not delivered', async () => {
|
||||
@@ -186,20 +193,23 @@ describe('User', () => {
|
||||
test('Pinning normal Note is delivered', async () => {
|
||||
pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 1);
|
||||
const _aliceInB = await vi.waitFor(async () => {
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 1);
|
||||
return _aliceInB;
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob);
|
||||
strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id);
|
||||
});
|
||||
|
||||
test('Unpinning normal Note is delivered', async () => {
|
||||
await alice.client.request('i/unpin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
await vi.waitFor(async () => {
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -224,7 +234,7 @@ describe('User', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
await waitForFollowRelation(alice, bob, 1);
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
@@ -247,7 +257,7 @@ describe('User', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/delete', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
await waitForFollowRelation(alice, bob, 0);
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
@@ -268,6 +278,13 @@ describe('User', () => {
|
||||
});
|
||||
|
||||
describe('Follow requests', () => {
|
||||
async function waitForFollowRequests(followee: LoginUser, count: number): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
const requests = await followee.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, count);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
}
|
||||
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
@@ -289,7 +306,7 @@ describe('User', () => {
|
||||
describe('Bob sends follow request to Alice', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRequests(alice, 1);
|
||||
});
|
||||
|
||||
test('Alice should have a request', async () => {
|
||||
@@ -303,7 +320,7 @@ describe('User', () => {
|
||||
describe('Alice cancels it', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/requests/cancel', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRequests(alice, 0);
|
||||
});
|
||||
|
||||
test('Alice should have no requests', async () => {
|
||||
@@ -316,10 +333,13 @@ describe('User', () => {
|
||||
describe('Send follow request from Bob to Alice and reject', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRequests(alice, 1);
|
||||
|
||||
await alice.client.request('following/requests/reject', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await vi.waitFor(async () => {
|
||||
const sent = await bob.client.request('following/requests/sent', {});
|
||||
strictEqual(sent.length, 0);
|
||||
}, WAIT_FOR_FEDERATION);
|
||||
});
|
||||
|
||||
test('Bob should have no requests', async () => {
|
||||
@@ -341,10 +361,10 @@ describe('User', () => {
|
||||
describe('Send follow request from Bob to Alice and accept', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
await waitForFollowRequests(alice, 1);
|
||||
|
||||
await alice.client.request('following/requests/accept', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForFollowing(bob, 1);
|
||||
});
|
||||
|
||||
test('Bob follows Alice', async () => {
|
||||
@@ -375,16 +395,10 @@ describe('User', () => {
|
||||
|
||||
test('Bob follows Alice, and Alice deleted themself', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
await waitForFollowRelation(bob, alice, 1); // followed by Bob
|
||||
|
||||
await alice.client.request('i/delete-account', { password: alice.password });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
await waitForFollowing(bob, 0, WAIT_FOR_SLOW_FEDERATION); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
@@ -414,10 +428,7 @@ describe('User', () => {
|
||||
|
||||
test('Bob follows Alice, then Alice gets deleted in B server', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
await waitForFollowRelation(bob, alice, 1); // followed by Bob
|
||||
|
||||
await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
@@ -473,16 +484,10 @@ describe('User', () => {
|
||||
|
||||
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
await waitForFollowRelation(bob, alice, 1); // followed by Bob
|
||||
|
||||
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
await waitForFollowing(bob, 0, WAIT_FOR_SLOW_FEDERATION); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
@@ -528,10 +533,9 @@ describe('User', () => {
|
||||
*/
|
||||
test('Alice can follow Bob', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await waitForFollowers(bob, 1); // followed by Alice
|
||||
|
||||
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(bobFollowers.length, 1); // followed by Alice
|
||||
assert(bobFollowers[0].follower != null);
|
||||
const renewedaliceInB = bobFollowers[0].follower;
|
||||
assert(aliceInB.username === renewedaliceInB.username);
|
||||
@@ -543,10 +547,7 @@ describe('User', () => {
|
||||
|
||||
// Bob tries to follow Alice
|
||||
await bob.client.request('following/create', { userId: renewedaliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(aliceFollowers.length, 1);
|
||||
await waitForFollowers(alice, 1);
|
||||
|
||||
// FIXME: but resolving still fails ...
|
||||
await rejects(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deepStrictEqual, strictEqual } from 'assert';
|
||||
import { vi } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -40,6 +41,21 @@ export async function sleep(ms = 250): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
type WaitForOptions = NonNullable<Parameters<typeof vi.waitFor>[1]>;
|
||||
|
||||
/** 連合の反映を待つ上限。`vi.waitFor()` を使わない待ち合わせ (ストリーミング) にも使う */
|
||||
export const FEDERATION_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* 連合の反映は非同期なので、固定時間の `sleep()` だけで待つと CI の負荷次第で容易に flaky になる。
|
||||
* 「一定時間待つ」のではなく「条件が満たされるまで待つ」ために `vi.waitFor()` へ渡すオプション。
|
||||
* (`vi.waitFor()` の既定値は timeout 1000ms / interval 50ms で、連合の反映待ちには短すぎる)
|
||||
*/
|
||||
export const WAIT_FOR_FEDERATION: WaitForOptions = { timeout: FEDERATION_TIMEOUT, interval: 250 };
|
||||
|
||||
/** アカウント削除・凍結など、明らかに時間のかかる処理を待つ場合の {@link WAIT_FOR_FEDERATION} */
|
||||
export const WAIT_FOR_SLOW_FEDERATION: WaitForOptions = { timeout: 30000, interval: 500 };
|
||||
|
||||
async function signin(
|
||||
host: Host,
|
||||
params: Misskey.entities.SigninFlowRequest,
|
||||
@@ -155,6 +171,37 @@ export async function createRole(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* フォローは Follow → Accept の往復で成立するので、`sleep()` で待つと
|
||||
* 「配送時点のフォロワー」に依存する配送 (Update / Note など) を取りこぼして flaky になる。
|
||||
* followee 側から見たフォロワー数が期待値になるまで待つ。
|
||||
*/
|
||||
export async function waitForFollowers(followee: LoginUser, count: number, options: WaitForOptions = WAIT_FOR_FEDERATION): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
const followers = await followee.client.request('users/followers', { userId: followee.id });
|
||||
strictEqual(followers.length, count);
|
||||
}, options);
|
||||
}
|
||||
|
||||
/** {@link waitForFollowers} の follower 側版 (Accept が返ってきたことを確認する) */
|
||||
export async function waitForFollowing(follower: LoginUser, count: number, options: WaitForOptions = WAIT_FOR_FEDERATION): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
const following = await follower.client.request('users/following', { userId: follower.id });
|
||||
strictEqual(following.length, count);
|
||||
}, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* フォロー関係が両方のサーバーで反映されるまで待つ。
|
||||
* 特に理由が無ければこちらを使う。
|
||||
*/
|
||||
export async function waitForFollowRelation(follower: LoginUser, followee: LoginUser, count: number, options: WaitForOptions = WAIT_FOR_FEDERATION): Promise<void> {
|
||||
await Promise.all([
|
||||
waitForFollowing(follower, count, options),
|
||||
waitForFollowers(followee, count, options),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function resolveRemoteUser(
|
||||
host: Host,
|
||||
id: string,
|
||||
@@ -224,6 +271,9 @@ export function deepStrictEqualWithExcludedFields<T>(actual: T, expected: T, exc
|
||||
deepStrictEqual(_actual, _expected);
|
||||
}
|
||||
|
||||
/** 「発火しない」ことの確認に使う待ち時間 */
|
||||
const NOT_FIRED_TIMEOUT = 500;
|
||||
|
||||
export async function isFired<C extends keyof Misskey.Channels, T extends keyof Misskey.Channels[C]['events']>(
|
||||
host: Host,
|
||||
user: { i: string },
|
||||
@@ -233,8 +283,11 @@ export async function isFired<C extends keyof Misskey.Channels, T extends keyof
|
||||
// @ts-expect-error TODO: why getting error here?
|
||||
cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean,
|
||||
params?: Misskey.Channels[C]['params'],
|
||||
timeout = NOT_FIRED_TIMEOUT,
|
||||
): Promise<boolean> {
|
||||
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
|
||||
// 先にイベントを受け取った場合でもタイマーが残り続けないよう、必ず解除する
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const connection = stream.useChannel(channel, params);
|
||||
|
||||
@@ -249,9 +302,12 @@ export async function isFired<C extends keyof Misskey.Channels, T extends keyof
|
||||
await trigger();
|
||||
return await Promise.race([
|
||||
receivePromise,
|
||||
sleep(500).then(() => false),
|
||||
new Promise<boolean>(resolve => {
|
||||
timer = setTimeout(() => resolve(false), timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
stream.close();
|
||||
}
|
||||
};
|
||||
@@ -262,8 +318,11 @@ export async function isNoteUpdatedEventFired(
|
||||
noteId: string,
|
||||
trigger: () => Promise<unknown>,
|
||||
cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean,
|
||||
timeout = NOT_FIRED_TIMEOUT,
|
||||
): Promise<boolean> {
|
||||
const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
|
||||
// 先にイベントを受け取った場合でもタイマーが残り続けないよう、必ず解除する
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
stream.send('s', { id: noteId });
|
||||
|
||||
@@ -279,9 +338,12 @@ export async function isNoteUpdatedEventFired(
|
||||
|
||||
return await Promise.race([
|
||||
receivePromise,
|
||||
sleep(500).then(() => false),
|
||||
new Promise<boolean>(resolve => {
|
||||
timer = setTimeout(() => resolve(false), timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
stream.close();
|
||||
}
|
||||
};
|
||||
@@ -293,11 +355,26 @@ export async function assertNotificationReceived(
|
||||
cond: (notification: Misskey.entities.Notification) => boolean,
|
||||
expect: boolean,
|
||||
) {
|
||||
const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond);
|
||||
const streamingFired = await isFired(
|
||||
receiverHost,
|
||||
receiver,
|
||||
'main',
|
||||
trigger,
|
||||
'notification',
|
||||
cond,
|
||||
undefined,
|
||||
expect ? FEDERATION_TIMEOUT : undefined,
|
||||
);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
const endpointFired = await receiver.client.request('i/notifications', {})
|
||||
const fetchEndpointFired = async () => await receiver.client.request('i/notifications', {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
.then(([notification]) => notification != null ? cond(notification) : false);
|
||||
strictEqual(endpointFired, expect);
|
||||
|
||||
if (expect) {
|
||||
await vi.waitFor(async () => strictEqual(await fetchEndpointFired(), true), WAIT_FOR_FEDERATION);
|
||||
} else {
|
||||
// 届かないことの確認は待っても意味がないので 1 回だけ確認する
|
||||
strictEqual(await fetchEndpointFired(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { mockDeep } from 'vitest-mock-extended';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { FileInfo, FileInfoService } from '@/core/FileInfoService.js';
|
||||
//import { DI } from '@/di-symbols.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
|
||||
@@ -41,13 +41,13 @@ describe('FileInfoService', () => {
|
||||
GlobalModule,
|
||||
],
|
||||
providers: [
|
||||
AiService,
|
||||
SensitiveMediaDetectionService,
|
||||
LoggerService,
|
||||
FileInfoService,
|
||||
],
|
||||
})
|
||||
.useMocker((token) => {
|
||||
//if (token === AiService) {
|
||||
//if (token === SensitiveMediaDetectionService) {
|
||||
// return { };
|
||||
//}
|
||||
if (typeof token === 'function') {
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import type { LoggerService } from '@/core/LoggerService.js';
|
||||
import { AiService, type Prediction } from '@/core/AiService.js';
|
||||
import { SensitiveMediaDetectionService, type Prediction } from '@/core/SensitiveMediaDetectionService.js';
|
||||
|
||||
const sendMock = vi.fn();
|
||||
|
||||
@@ -18,13 +18,13 @@ const DEFAULT_META = {
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: 4,
|
||||
};
|
||||
|
||||
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
|
||||
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): SensitiveMediaDetectionService {
|
||||
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
|
||||
const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
|
||||
const loggerService = {
|
||||
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
||||
} as unknown as LoggerService;
|
||||
return new AiService(meta, httpRequestService, loggerService);
|
||||
return new SensitiveMediaDetectionService(meta, httpRequestService, loggerService);
|
||||
}
|
||||
|
||||
function neutral(): Prediction[] {
|
||||
@@ -42,7 +42,7 @@ function okResponse(results: unknown[]) {
|
||||
|
||||
const buf = (s: string) => Buffer.from(s);
|
||||
|
||||
describe('AiService', () => {
|
||||
describe('SensitiveMediaDetectionService', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { describe, expect, test, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
import { DataSource, type Repository } from 'typeorm';
|
||||
import { initTestDb, randomString } from '../../utils.js';
|
||||
import type { AiService } from '@/core/AiService.js';
|
||||
import type { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { FileInfoService } from '@/core/FileInfoService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
@@ -147,11 +147,11 @@ describe('FileServerService', () => {
|
||||
driveFilesRepository = db.getRepository(MiDriveFile);
|
||||
|
||||
const loggerService = new LoggerService();
|
||||
const aiService = {
|
||||
const sensitiveMediaDetectionService = {
|
||||
detectSensitive: async () => null,
|
||||
detectSensitiveMany: async (sources: Buffer[]) => sources.map(() => null),
|
||||
} as unknown as AiService;
|
||||
const fileInfoService = new FileInfoService(aiService, loggerService);
|
||||
} as unknown as SensitiveMediaDetectionService;
|
||||
const fileInfoService = new FileInfoService(sensitiveMediaDetectionService, loggerService);
|
||||
const httpRequestService = new HttpRequestService(config);
|
||||
const downloadService = new DownloadService(config, httpRequestService, loggerService);
|
||||
const imageProcessingService = new ImageProcessingService();
|
||||
|
||||
@@ -6,6 +6,8 @@ export default mergeConfig(
|
||||
defineConfig({
|
||||
test: {
|
||||
include: ['test-federation/test/**/*.test.ts'],
|
||||
// beforeAll / afterAll で連合の反映 (最大 WAIT_FOR_SLOW_FEDERATION) を待つ
|
||||
hookTimeout: 60000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.9",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"rollup": "4.62.2"
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"rollup": "4.62.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"magic-string": "0.30.21",
|
||||
"oxc-walker": "1.0.0",
|
||||
"rolldown": "1.1.5",
|
||||
"vite": "8.1.4"
|
||||
"magic-string": "1.1.0",
|
||||
"oxc-walker": "1.1.1",
|
||||
"rolldown": "1.2.3",
|
||||
"vite": "8.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
"mfm-js": "0.26.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"shiki": "4.3.1",
|
||||
"shiki": "4.4.3",
|
||||
"tinycolor2": "1.6.0",
|
||||
"uuid": "14.0.1",
|
||||
"vue": "3.5.39"
|
||||
"vue": "3.5.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
@@ -30,25 +30,25 @@
|
||||
"@rollup/pluginutils": "5.4.0",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/runtime-core": "3.5.39",
|
||||
"@vitejs/plugin-vue": "6.0.8",
|
||||
"@vue/runtime-core": "3.5.41",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"eslint-plugin-vue": "10.10.0",
|
||||
"intersection-observer": "0.12.2",
|
||||
"lightningcss": "1.32.0",
|
||||
"lightningcss": "1.33.0",
|
||||
"sass-embedded": "1.100.0",
|
||||
"tsx": "4.23.0",
|
||||
"typescript": "6.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vue-component-type-helpers": "3.3.7",
|
||||
"tsx": "4.23.12",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.2.1",
|
||||
"vue-component-type-helpers": "3.3.9",
|
||||
"vue-eslint-parser": "10.4.1",
|
||||
"vue-tsc": "3.3.7"
|
||||
"vue-tsc": "3.3.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"eslint-plugin-vue": "10.10.0",
|
||||
"vue-eslint-parser": "10.4.1"
|
||||
},
|
||||
"files": [
|
||||
@@ -23,8 +23,8 @@
|
||||
"i18n": "workspace:*",
|
||||
"json5": "2.2.3",
|
||||
"misskey-js": "workspace:*",
|
||||
"shiki": "4.3.1",
|
||||
"shiki": "4.4.3",
|
||||
"tinycolor2": "1.6.0",
|
||||
"vue": "3.5.39"
|
||||
"vue": "3.5.41"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
"@misskey-dev/browser-image-resizer": "2024.1.0",
|
||||
"@misskey-dev/emoji-data": "17.0.3",
|
||||
"@misskey-dev/tagcanvas-es": "0.1.2",
|
||||
"@sentry/vue": "10.65.0",
|
||||
"@sentry/vue": "10.70.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@syuilo/aiscript": "1.2.1",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vitejs/plugin-vue": "6.0.8",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
|
||||
"analytics": "0.8.19",
|
||||
"broadcast-channel": "7.3.0",
|
||||
"broadcast-channel": "7.4.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"chart.js": "4.5.1",
|
||||
@@ -42,7 +42,7 @@
|
||||
"cropperjs": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
"eventemitter3": "5.0.4",
|
||||
"exifreader": "4.41.0",
|
||||
"exifreader": "4.42.0",
|
||||
"frontend-shared": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
"icons-subsetter": "workspace:*",
|
||||
@@ -52,7 +52,7 @@
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.50.8",
|
||||
"mediabunny": "1.53.0",
|
||||
"mfm-js": "0.26.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
@@ -62,23 +62,23 @@
|
||||
"qr-scanner": "1.4.2",
|
||||
"sanitize-html": "2.17.6",
|
||||
"seedrandom": "3.0.5",
|
||||
"shiki": "4.3.1",
|
||||
"shiki": "4.4.3",
|
||||
"textarea-caret": "3.1.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vue": "3.5.39",
|
||||
"vue": "3.5.41",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
"@misskey-dev/summaly": "5.5.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/pluginutils": "5.4.0",
|
||||
"@storybook/addon-essentials": "8.6.18",
|
||||
"@storybook/addon-interactions": "8.6.18",
|
||||
"@storybook/addon-links": "10.5.0",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/addon-mdx-gfm": "8.6.18",
|
||||
"@storybook/addon-storysource": "8.6.18",
|
||||
"@storybook/blocks": "8.6.18",
|
||||
@@ -89,8 +89,8 @@
|
||||
"@storybook/test": "8.6.18",
|
||||
"@storybook/theming": "8.6.18",
|
||||
"@storybook/types": "8.6.18",
|
||||
"@storybook/vue3": "10.5.0",
|
||||
"@storybook/vue3-vite": "10.5.0",
|
||||
"@storybook/vue3": "10.5.7",
|
||||
"@storybook/vue3-vite": "10.5.7",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
@@ -98,50 +98,50 @@
|
||||
"@types/insert-text-at-cursor": "0.3.2",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@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.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vue/compiler-core": "3.5.39",
|
||||
"@vue/compiler-core": "3.5.41",
|
||||
"astring": "1.9.0",
|
||||
"chromatic": "18.0.1",
|
||||
"chromatic": "18.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"execa": "9.6.1",
|
||||
"happy-dom": "20.10.6",
|
||||
"eslint-plugin-vue": "10.10.0",
|
||||
"execa": "10.0.1",
|
||||
"happy-dom": "20.11.2",
|
||||
"intersection-observer": "0.12.2",
|
||||
"lightningcss": "1.32.0",
|
||||
"lightningcss": "1.33.0",
|
||||
"micromatch": "4.0.8",
|
||||
"minimatch": "10.2.5",
|
||||
"minimatch": "10.2.6",
|
||||
"msw": "2.15.0",
|
||||
"msw-storybook-addon": "2.0.7",
|
||||
"msw-storybook-addon": "3.0.0",
|
||||
"nodemon": "3.1.14",
|
||||
"oxc-walker": "1.0.0",
|
||||
"playwright": "1.61.1",
|
||||
"prettier": "3.9.5",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"rolldown": "1.1.5",
|
||||
"oxc-walker": "1.1.1",
|
||||
"playwright": "1.62.1",
|
||||
"prettier": "3.9.6",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"rolldown": "1.2.3",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"sass-embedded": "1.100.0",
|
||||
"storybook": "10.5.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.23.0",
|
||||
"typescript": "6.0.2",
|
||||
"vite": "8.1.4",
|
||||
"vite-plugin-glsl": "1.6.0",
|
||||
"tsx": "4.23.12",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-glsl": "1.6.1",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "4.1.10",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.3.7",
|
||||
"vue-component-type-helpers": "3.3.9",
|
||||
"vue-eslint-parser": "10.4.1",
|
||||
"vue-tsc": "3.3.7"
|
||||
"vue-tsc": "3.3.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ watch([changed, invalid], ([newChanged, newInvalid]) => {
|
||||
}, { immediate: true });
|
||||
|
||||
// このコンポーネントが作成された時、非表示状態である場合がある
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、ResizeObserverでサイズの変化を監視して計算する
|
||||
const updatePadding = (entries: ResizeObserverEntry[]) => {
|
||||
if (inputEl.value == null) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<!-- keyを変えて要素ごと作り直すことで、AudioContextに繋いだ要素を確実に切り離す (onLoadErrorを参照) -->
|
||||
<audio
|
||||
:key="audioElKey"
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
:crossorigin="crossOriginMode === 'anonymous' ? 'anonymous' : undefined"
|
||||
:src="content.url"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@error="onLoadError"
|
||||
></audio>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
width="1600"
|
||||
height="900"
|
||||
data-gallery-click-action="media"
|
||||
:class="$style.visualizer"
|
||||
v-bind="$attrs"
|
||||
></canvas>
|
||||
<div v-if="!isPlaying" :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, shallowRef, ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { themeManager } from '@/theme.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { store } from '@/store.js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
|
||||
// クリック等のフォールスルーはキャンバスに渡す (ルートは全面を覆うので、その外側は背景として扱わせる)
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
content: Content;
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
isPlaying: boolean;
|
||||
volume: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'loadedmetadata'): void;
|
||||
}>();
|
||||
|
||||
const audioEl = useTemplateRef('audioEl');
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
const canvasCtx = computed(() => canvasEl.value?.getContext('2d') ?? null);
|
||||
|
||||
const fileUser = computed(() => props.content.file?.user ?? props.user);
|
||||
|
||||
//#region 描画パラメータ
|
||||
// 低域しか見ないので、その範囲を十分な bin 数で刻めるよう大きめの FFT を使う (48kHz で bin 幅 ≒ 5.9Hz)
|
||||
const FFT_SIZE = 8192;
|
||||
const BIN_COUNT = FFT_SIZE / 2;
|
||||
|
||||
/** 波形の制御点数 (片側)。実際の輪郭はこれを左右にミラーした 2 倍の点で構成される */
|
||||
const BAND_COUNT = 36;
|
||||
/** スペクトルとして取り出す周波数レンジ。波形もビート判定もこの低域だけを見る */
|
||||
const MIN_FREQ = 30;
|
||||
const MAX_FREQ = 300;
|
||||
|
||||
/** 波形の立ち上がり / 立ち下がりの時定数 (s) */
|
||||
const WAVE_ATTACK_TAU = 0.01;
|
||||
const WAVE_RELEASE_TAU = 0.015;
|
||||
/** 正規化後にかけるコントラスト強調の指数 大きいほど小さい山が引っ込み、大きい山との差が開く */
|
||||
const CONTRAST_EXPONENT = 1.9;
|
||||
/** 基準半径に対する波形の振れ幅 */
|
||||
const WAVE_GAIN = 1.05;
|
||||
/** Catmull-Rom スプラインの張り具合 大きいほど丸く、0 に近いほど折れ線に近づく */
|
||||
const SPLINE_TENSION = 0.9;
|
||||
|
||||
/** 全体の音圧に追従する緩やかな拡大縮小の時定数 (s) と最大量 */
|
||||
const LEVEL_TAU = 0.3;
|
||||
const LEVEL_SCALE = 0.1;
|
||||
/** ビート検出用のエンベロープ / その移動平均の時定数 (s) */
|
||||
const BEAT_ATTACK_TAU = 0.012;
|
||||
const BEAT_FALL_TAU = 0.11;
|
||||
const BEAT_BASELINE_TAU = 0.7;
|
||||
/** 移動平均からの超過分をビートとみなす際の増幅率と、減衰の時定数 (s) */
|
||||
const BEAT_SENSITIVITY = 3;
|
||||
const BEAT_RELEASE_TAU = 0.18;
|
||||
/** ビートに合わせて瞬間的に拡大する量 */
|
||||
const BEAT_SCALE = 0.16;
|
||||
|
||||
/** キャンバス短辺の半分に対する、無音時の波形半径の比 (アバターの直径もこれに一致する) */
|
||||
const BASE_RADIUS_RATIO = 0.36;
|
||||
//#endregion
|
||||
|
||||
// アニメーション量を控えるべきかどうか(拡大縮小のみ抑制)
|
||||
const motionDamp = prefer.s.animation ? 1 : 0.3;
|
||||
|
||||
//#region 解析の状態
|
||||
const freqArray = new Uint8Array(BIN_COUNT);
|
||||
/** 帯域ごとに参照する bin の範囲 */
|
||||
const bandStart = new Uint16Array(BAND_COUNT);
|
||||
const bandEnd = new Uint16Array(BAND_COUNT);
|
||||
/** 帯域ごとの生の振幅 (フレーム内の作業用) */
|
||||
const rawLevels = new Float32Array(BAND_COUNT);
|
||||
/** 帯域ごとの平滑化済み振幅 (0-1) */
|
||||
const levels = new Float32Array(BAND_COUNT);
|
||||
/** 輪郭の頂点座標 */
|
||||
const pointsX = new Float64Array(BAND_COUNT * 2);
|
||||
const pointsY = new Float64Array(BAND_COUNT * 2);
|
||||
|
||||
/** 自動レンジ調整が追跡している下限 / 上限。低域だけを見るので全帯域で 1 つのレンジを共有する */
|
||||
let rangeFloor = 0;
|
||||
let rangeCeil = 0;
|
||||
let levelEnv = 0;
|
||||
let energyEnv = 0;
|
||||
let energyBaseline = 0;
|
||||
let beatEnv = 0;
|
||||
|
||||
function setupBands(sampleRate: number) {
|
||||
const ratio = MAX_FREQ / MIN_FREQ;
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
const lowFreq = MIN_FREQ * Math.pow(ratio, i / BAND_COUNT);
|
||||
const highFreq = MIN_FREQ * Math.pow(ratio, (i + 1) / BAND_COUNT);
|
||||
const toBin = (freq: number) => freq * FFT_SIZE / sampleRate;
|
||||
const start = Math.min(Math.max(Math.floor(toBin(lowFreq)), 0), BIN_COUNT - 1);
|
||||
const end = Math.min(Math.max(Math.ceil(toBin(highFreq)), start + 1), BIN_COUNT);
|
||||
bandStart[i] = start;
|
||||
bandEnd[i] = end;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAnalysis() {
|
||||
levels.fill(0);
|
||||
rangeFloor = 0;
|
||||
rangeCeil = 0;
|
||||
levelEnv = 0;
|
||||
energyEnv = 0;
|
||||
energyBaseline = 0;
|
||||
beatEnv = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指数平滑。フレームレートに依存しないよう、経過時間から係数を求める
|
||||
* @param tau 目標値の約 63% まで近づくのにかかる秒数
|
||||
*/
|
||||
function approach(current: number, target: number, tau: number, dt: number) {
|
||||
return current + (target - current) * (1 - Math.exp(-dt / tau));
|
||||
}
|
||||
|
||||
/** 下限が下がる / 上がるときの時定数 (s) 上げ側を遅くして、鳴っている間に基準が持ち上がらないようにする */
|
||||
const RANGE_FLOOR_FALL_TAU = 0.25;
|
||||
const RANGE_FLOOR_RISE_TAU = 4;
|
||||
/** 上限が下がるときの時定数 (s) 上がるときは即時 */
|
||||
const RANGE_CEIL_FALL_TAU = 1.4;
|
||||
/** レンジ幅の下限。これ以下に潰れたときに無理な増幅をしない */
|
||||
const MIN_RANGE_SPAN = 0.14;
|
||||
|
||||
/**
|
||||
* フレーム内の最小 / 最大値から、正規化に使うレンジを更新して幅を返す (自動レンジ調整)。
|
||||
* 音源ごとの音量差を吸収しつつ、下限をゆっくり、上限を即時に追従させて短時間のダイナミクスは残す
|
||||
*/
|
||||
function updateRange(frameMin: number, framePeak: number, dt: number) {
|
||||
rangeFloor = approach(rangeFloor, frameMin, frameMin < rangeFloor ? RANGE_FLOOR_FALL_TAU : RANGE_FLOOR_RISE_TAU, dt);
|
||||
// 上限は即座に持ち上げ、ゆっくり戻す。戻り先に下限 + 最小幅を混ぜて、レンジが潰れないようにする
|
||||
rangeCeil = framePeak > rangeCeil ? framePeak : approach(rangeCeil, Math.max(framePeak, rangeFloor + MIN_RANGE_SPAN), RANGE_CEIL_FALL_TAU, dt);
|
||||
return Math.max(rangeCeil - rangeFloor, MIN_RANGE_SPAN);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const defaultBgColor = themeManager.currentCompiledTheme?.accent ?? '#aaa';
|
||||
const accentColorHue = computed(() => tinycolor(fileUser.value?.avatarBlurhash ? extractAvgColorFromBlurhash(fileUser.value.avatarBlurhash) ?? defaultBgColor : defaultBgColor).toHsl().h);
|
||||
const bgColor = computed(() => {
|
||||
let targetLightness, targetSaturation;
|
||||
if (store.r.darkMode.value) {
|
||||
targetLightness = 0.1;
|
||||
targetSaturation = 0.8;
|
||||
} else {
|
||||
targetLightness = 0.9;
|
||||
targetSaturation = 0.9;
|
||||
}
|
||||
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
|
||||
});
|
||||
const fgColor = computed(() => {
|
||||
let targetLightness, targetSaturation;
|
||||
if (store.r.darkMode.value) {
|
||||
targetLightness = 0.25;
|
||||
targetSaturation = 0.8;
|
||||
} else {
|
||||
targetLightness = 0.7;
|
||||
targetSaturation = 0.7;
|
||||
}
|
||||
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
|
||||
});
|
||||
const messageColor = computed(() => {
|
||||
let targetLightness, targetSaturation;
|
||||
if (store.r.darkMode.value) {
|
||||
targetLightness = 0.75;
|
||||
targetSaturation = 0.4;
|
||||
} else {
|
||||
targetLightness = 0.25;
|
||||
targetSaturation = 0.4;
|
||||
}
|
||||
return `hsl(${accentColorHue.value}, ${targetSaturation * 100}%, ${targetLightness * 100}%)`;
|
||||
});
|
||||
|
||||
// 読み込みが終わるまでは描画しない (読み込み完了時の描き直しは下のwatchで行う)
|
||||
const avatarImage = shallowRef<HTMLImageElement | null>(null);
|
||||
|
||||
//#region audio graph
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let audioSource: MediaElementAudioSourceNode | null = null;
|
||||
let analyserNode: AnalyserNode | null = null;
|
||||
let gainNode: GainNode | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
/** 解析用のオーディオグラフを組めたかどうか。組めなかった場合は波形の代わりに文言を出す */
|
||||
let isVisualizerAvailable = true;
|
||||
|
||||
// AnalyserNodeはCORS的に読めない音源に対して無音を返すが、その際に例外もイベントも発生しない。
|
||||
// しかも一度 createMediaElementSource() に渡した要素は元の出力に戻せないため、
|
||||
// 「読み込み方を先に決める」「駄目なら要素ごと作り直す」の二段構えにする
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url, window.location.href).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一オリジンの音源はCORS属性なしでも解析できる */
|
||||
const isSameOriginContent = computed(() => isSameOrigin(props.content.url));
|
||||
/** CORS付きでの読み込みに失敗した音源 */
|
||||
const corsFailedUrl = ref<string | null>(null);
|
||||
/** フォールバックで要素を作り直した後、再生を再開すべき音源 */
|
||||
let resumeAfterReloadUrl: string | null = null;
|
||||
|
||||
/** `anonymous`: CORS付きで読み込む (解析できる) / `none`: CORS無しで読み込む (同一オリジンでない場合は再生のみ) */
|
||||
const crossOriginMode = computed<'anonymous' | 'none'>(() => (isSameOriginContent.value || corsFailedUrl.value === props.content.url) ? 'none' : 'anonymous');
|
||||
const canUseAudioGraph = computed(() => isSameOriginContent.value || crossOriginMode.value === 'anonymous');
|
||||
|
||||
// 読み込み方が変わったときと音源が差し替わったときに<audio>を作り直す。
|
||||
// 同じ要素を2度 createMediaElementSource() に渡すことはできないので、
|
||||
// グラフの組み直しが要る場面では必ず新しい要素を用意する必要がある
|
||||
const audioElKey = computed(() => `${crossOriginMode.value}\n${props.content.url}`);
|
||||
|
||||
/** 現在の要素でメタデータまで到達できたか。到達していればCORSのチェックは通過している */
|
||||
let hasLoadedMetadata = false;
|
||||
/** 現在の要素に対して再生が要求されたか */
|
||||
let isPlayRequested = false;
|
||||
|
||||
function onLoadedMetadata() {
|
||||
hasLoadedMetadata = true;
|
||||
emit('loadedmetadata');
|
||||
}
|
||||
|
||||
function onLoadError() {
|
||||
// CORSヘッダを返さないサーバーでは crossorigin 付きの読み込みが失敗するが、
|
||||
// MediaErrorからは原因を判別できないので、理由を問わず一度だけCORS無しで読み直す
|
||||
// (本当に壊れているファイルなら再試行も失敗し、同じエラー状態に落ち着く)。
|
||||
// ただしメタデータまで読めていたならCORSは通過済みなので、再生中のネットワーク断や
|
||||
// デコード失敗を拾ってフォールバックしてしまわないようにする
|
||||
if (crossOriginMode.value !== 'anonymous' || hasLoadedMetadata) return;
|
||||
|
||||
// 読み込み前に失敗しているので再生位置は0のまま。再生の要求だけ引き継げばよい
|
||||
resumeAfterReloadUrl = isPlayRequested ? props.content.url : null;
|
||||
corsFailedUrl.value = props.content.url;
|
||||
}
|
||||
|
||||
let visualizerTickFrameId: number | null = null;
|
||||
let lastTickTimestamp = 0;
|
||||
|
||||
function resumeAudioCtx() {
|
||||
if (audioCtx == null || audioCtx.state !== 'suspended') return;
|
||||
audioCtx.resume().catch(err => {
|
||||
console.error('Failed to resume AudioContext:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function visualizerTick(timestamp: number) {
|
||||
// タブがバックグラウンドに回っている間は rAF が止まるので、復帰時の巨大な dt は 0.1s で頭打ちにする
|
||||
const dt = lastTickTimestamp === 0 ? 1 / 60 : Math.min((timestamp - lastTickTimestamp) / 1000, 0.1);
|
||||
lastTickTimestamp = timestamp;
|
||||
|
||||
draw(dt);
|
||||
|
||||
visualizerTickFrameId = window.requestAnimationFrame(visualizerTick);
|
||||
}
|
||||
|
||||
function startVisualizerTick() {
|
||||
if (visualizerTickFrameId != null) return;
|
||||
lastTickTimestamp = 0;
|
||||
visualizerTickFrameId = window.requestAnimationFrame(visualizerTick);
|
||||
}
|
||||
|
||||
function stopVisualizerTick() {
|
||||
if (visualizerTickFrameId == null) return;
|
||||
window.cancelAnimationFrame(visualizerTickFrameId);
|
||||
visualizerTickFrameId = null;
|
||||
lastTickTimestamp = 0;
|
||||
}
|
||||
|
||||
function setPlaying(playing: boolean) {
|
||||
if (playing && isVisualizerAvailable) {
|
||||
startVisualizerTick();
|
||||
} else {
|
||||
// 一時停止中はその時点の波形・スケールをそのまま保つ (キャンバスは最後に描いたフレームを保持する)
|
||||
stopVisualizerTick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析用のオーディオグラフを組む。組めなかった場合は false を返す
|
||||
* (AudioContext を作れない環境や、要素が既に別の AudioContext に接続済みの場合など)
|
||||
*/
|
||||
function setupAudioGraph(el: HTMLAudioElement) {
|
||||
try {
|
||||
audioCtx = new AudioContext();
|
||||
analyserNode = audioCtx.createAnalyser();
|
||||
analyserNode.fftSize = FFT_SIZE;
|
||||
// FFT の窓自体が長く (≒ 170ms) 平滑がかかるうえ、時間ベースの平滑は analyse() 側で行うので弱めにする
|
||||
analyserNode.smoothingTimeConstant = 0.3;
|
||||
// 既定の -100〜-30dB は音楽素材に対して下が広すぎるので、実用レンジに寄せて 0-255 を使い切る
|
||||
analyserNode.minDecibels = -90;
|
||||
analyserNode.maxDecibels = -25;
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = props.volume;
|
||||
audioSource = audioCtx.createMediaElementSource(el);
|
||||
|
||||
// ビジュアライザー用 (解析するだけなので destination には繋がない)
|
||||
audioSource.connect(analyserNode);
|
||||
// 再生用
|
||||
audioSource.connect(gainNode).connect(audioCtx.destination);
|
||||
|
||||
setupBands(audioCtx.sampleRate);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to set up the audio graph for the visualizer:', err);
|
||||
teardownAudioGraph();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const el = audioEl.value;
|
||||
if (el == null) return;
|
||||
|
||||
// 前の音源の波形を持ち越さない
|
||||
resetAnalysis();
|
||||
|
||||
// CORS的に読めない音源をグラフに繋ぐと再生まで無音になるため、その場合は繋がずに素の再生に任せる
|
||||
isVisualizerAvailable = canUseAudioGraph.value && setupAudioGraph(el);
|
||||
|
||||
if (isVisualizerAvailable) {
|
||||
// 音量制御はGainNodeが担当するため、要素側は常に100%
|
||||
// (ミュートは音量0として表現する。要素をmutedにするとタップまで無音になり波形が消える)
|
||||
el.volume = 1;
|
||||
} else {
|
||||
// グラフを組めなかったときは要素側で音量を制御する (ビジュアライザは諦めるが再生はできる)
|
||||
el.volume = props.volume;
|
||||
}
|
||||
el.muted = false;
|
||||
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const on = (type: keyof HTMLMediaElementEventMap, listener: () => void) => {
|
||||
el.addEventListener(type, listener, { signal });
|
||||
};
|
||||
|
||||
// 再生状態: メディア要素のイベントを唯一の情報源にすることで、このコンポーネント経由でない
|
||||
// 操作 (コントロール・キーボード・OSのメディアキー等) でも波形の描画と同期がとれる
|
||||
on('play', () => {
|
||||
isPlayRequested = true;
|
||||
resumeAudioCtx();
|
||||
});
|
||||
on('playing', () => setPlaying(true));
|
||||
on('waiting', () => setPlaying(false));
|
||||
on('pause', () => setPlaying(false));
|
||||
on('ended', () => setPlaying(false));
|
||||
on('emptied', () => setPlaying(false));
|
||||
|
||||
// 現在の要素の状態を取り込む (コンポーネントの準備前に再生が始まっている場合等)。
|
||||
if (!el.paused) resumeAudioCtx();
|
||||
setPlaying(!el.paused);
|
||||
|
||||
// フォールバックでaudio要素を作り直す前に再生が要求されていたなら、新しい要素で再生し直す
|
||||
if (resumeAfterReloadUrl === props.content.url) {
|
||||
resumeAfterReloadUrl = null;
|
||||
el.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// 波形が回らないケース (停止中、またはビジュアライザを描画できない場合) はここで一度だけ描く
|
||||
redrawIfStopped();
|
||||
}
|
||||
|
||||
function teardownAudioGraph() {
|
||||
audioSource?.disconnect();
|
||||
audioSource = null;
|
||||
analyserNode?.disconnect();
|
||||
analyserNode = null;
|
||||
gainNode?.disconnect();
|
||||
gainNode = null;
|
||||
if (audioCtx != null && audioCtx.state !== 'closed') {
|
||||
audioCtx.close().catch(err => {
|
||||
console.error('Failed to close AudioContext:', err);
|
||||
});
|
||||
}
|
||||
audioCtx = null;
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
stopVisualizerTick();
|
||||
teardownAudioGraph();
|
||||
isVisualizerAvailable = true;
|
||||
hasLoadedMetadata = false;
|
||||
isPlayRequested = false;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 解析
|
||||
/** 無音ゲート フレーム平均がこの下限からこの幅の間で 0→1 になる */
|
||||
const GATE_FLOOR = 0.02;
|
||||
const GATE_RANGE = 0.08;
|
||||
|
||||
/**
|
||||
* スペクトルを帯域ごとの振幅に落とし込み、エンベロープ (波形・音圧・ビート) を更新する
|
||||
*/
|
||||
function analyse(dt: number) {
|
||||
if (analyserNode == null) return;
|
||||
analyserNode.getByteFrequencyData(freqArray);
|
||||
|
||||
// 帯域ごとの生の振幅を求める
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
let max = 0;
|
||||
let total = 0;
|
||||
for (let bin = bandStart[i]; bin < bandEnd[i]; bin++) {
|
||||
const v = freqArray[bin];
|
||||
total += v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
const binCount = bandEnd[i] - bandStart[i];
|
||||
// 最大値寄りにブレンドすると、幅の広い帯域でも細いピークが平均に埋もれず輪郭が動く
|
||||
rawLevels[i] = (max * 0.7 + (total / binCount) * 0.3) / 255;
|
||||
}
|
||||
|
||||
// 隣接帯域どうしをならして山をなだらかにする
|
||||
let prev = rawLevels[0];
|
||||
let sum = 0;
|
||||
let frameMin = 1;
|
||||
let framePeak = 0;
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
const current = rawLevels[i];
|
||||
const next = i + 1 < BAND_COUNT ? rawLevels[i + 1] : current;
|
||||
const smoothed = (prev + current * 3 + next) / 5;
|
||||
rawLevels[i] = smoothed;
|
||||
prev = current;
|
||||
|
||||
sum += smoothed;
|
||||
if (smoothed < frameMin) frameMin = smoothed;
|
||||
if (smoothed > framePeak) framePeak = smoothed;
|
||||
}
|
||||
|
||||
// 無音ゲート: 自動レンジ調整は微小なノイズも最大まで引き伸ばしてしまうので、
|
||||
// フレーム全体のエネルギーが無いときは強制的に閉じる
|
||||
const frameMean = sum / BAND_COUNT;
|
||||
const gate = Math.min(Math.max((frameMean - GATE_FLOOR) / GATE_RANGE, 0), 1);
|
||||
|
||||
const span = updateRange(frameMin, framePeak, dt);
|
||||
|
||||
for (let i = 0; i < BAND_COUNT; i++) {
|
||||
const normalized = Math.min(Math.max((rawLevels[i] - rangeFloor) / span, 0), 1);
|
||||
const target = Math.pow(normalized, CONTRAST_EXPONENT) * gate;
|
||||
|
||||
levels[i] = approach(levels[i], target, target > levels[i] ? WAVE_ATTACK_TAU : WAVE_RELEASE_TAU, dt);
|
||||
}
|
||||
|
||||
// 低域全体のエネルギー。音圧による拡大縮小とビート検出の両方がこれを共有する
|
||||
const energy = Math.min(Math.max((frameMean - rangeFloor) / span, 0), 1) * gate;
|
||||
|
||||
// 全体の音圧: 緩やかに追従させ、曲の盛り上がりに合わせてじわっと拡大縮小させる
|
||||
levelEnv = approach(levelEnv, energy, LEVEL_TAU, dt);
|
||||
|
||||
// ビート: エネルギーが自身の移動平均をどれだけ超えたかで検出する
|
||||
energyEnv = approach(energyEnv, energy, energy > energyEnv ? BEAT_ATTACK_TAU : BEAT_FALL_TAU, dt);
|
||||
energyBaseline = approach(energyBaseline, energyEnv, BEAT_BASELINE_TAU, dt);
|
||||
const hit = Math.min(Math.max(energyEnv - energyBaseline, 0) * BEAT_SENSITIVITY, 1);
|
||||
beatEnv = hit > beatEnv ? hit : approach(beatEnv, hit, BEAT_RELEASE_TAU, dt);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 描画
|
||||
/**
|
||||
* 帯域の振幅を左右対称の閉じた輪郭として塗る
|
||||
*/
|
||||
function fillWave(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, baseRadius: number) {
|
||||
const total = BAND_COUNT * 2;
|
||||
|
||||
for (let k = 0; k < total; k++) {
|
||||
// 前半は上→下、後半はその鏡像で下→上をたどる
|
||||
const bandIndex = k < BAND_COUNT ? k : total - 1 - k;
|
||||
const radius = baseRadius * (1 + levels[bandIndex] * WAVE_GAIN);
|
||||
// 真上を起点に、半周を BAND_COUNT 等分する (両端を半ステップずらして点の重複を避ける)
|
||||
const angle = -Math.PI / 2 + Math.PI * (k + 0.5) / BAND_COUNT;
|
||||
pointsX[k] = centerX + radius * Math.cos(angle);
|
||||
pointsY[k] = centerY + radius * Math.sin(angle);
|
||||
}
|
||||
|
||||
// Catmull-Rom スプラインを 3 次ベジェに変換して描く (曲線が頂点そのものを通るので、単独の山が潰れない)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pointsX[0], pointsY[0]);
|
||||
for (let k = 0; k < total; k++) {
|
||||
const prev = (k - 1 + total) % total;
|
||||
const next = (k + 1) % total;
|
||||
const nextNext = (k + 2) % total;
|
||||
ctx.bezierCurveTo(
|
||||
pointsX[k] + (pointsX[next] - pointsX[prev]) * SPLINE_TENSION / 6,
|
||||
pointsY[k] + (pointsY[next] - pointsY[prev]) * SPLINE_TENSION / 6,
|
||||
pointsX[next] - (pointsX[nextNext] - pointsX[k]) * SPLINE_TENSION / 6,
|
||||
pointsY[next] - (pointsY[nextNext] - pointsY[k]) * SPLINE_TENSION / 6,
|
||||
pointsX[next],
|
||||
pointsY[next],
|
||||
);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dt 前フレームからの経過時間 (s) 0 を渡すと解析を進めず、現在の状態をそのまま描き直す
|
||||
*/
|
||||
function draw(dt: number) {
|
||||
const canvas = canvasEl.value;
|
||||
const ctx = canvasCtx.value;
|
||||
if (canvas == null || ctx == null) return;
|
||||
|
||||
if (dt > 0) analyse(dt);
|
||||
|
||||
ctx.fillStyle = bgColor.value;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
// 全体が拡大縮小する
|
||||
// (ビジュアライザを描画できない場合は解析が走らずエンベロープが 0 のままなので、自然に等倍になる)
|
||||
const scale = 1 + (levelEnv * LEVEL_SCALE + beatEnv * BEAT_SCALE) * motionDamp;
|
||||
const baseRadius = Math.min(centerX, centerY) * BASE_RADIUS_RATIO * scale;
|
||||
|
||||
if (isVisualizerAvailable) {
|
||||
ctx.fillStyle = fgColor.value;
|
||||
fillWave(ctx, centerX, centerY, baseRadius);
|
||||
}
|
||||
|
||||
// 波形の中心にアバターを円形にくりぬいて描画 (波形が出せない場合もアバターは出す)
|
||||
const avatar = avatarImage.value;
|
||||
if (avatar != null) {
|
||||
const avatarSize = baseRadius * 2;
|
||||
const avatarHeight = Math.max(avatar.height * (avatarSize / avatar.width), avatarSize);
|
||||
const avatarWidth = Math.max(avatar.width * (avatarSize / avatar.height), avatarSize);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, avatarSize / 2, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.drawImage(avatar, centerX - avatarWidth / 2, centerY - avatarHeight / 2, avatarWidth, avatarHeight);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (!isVisualizerAvailable) {
|
||||
// 再生自体はできるので、アバターの下に文言を添えるだけに留める
|
||||
ctx.fillStyle = messageColor.value;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${Math.round(canvas.height * 0.055)}px ${window.getComputedStyle(canvas).fontFamily}`;
|
||||
ctx.fillText(i18n.ts.cannotPreview, centerX, centerY + baseRadius + canvas.height * 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止中は描画の機会が無いので、表示内容が変わったときに自前で描き直す */
|
||||
function redrawIfStopped() {
|
||||
if (visualizerTickFrameId == null) draw(0);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
watch(() => props.volume, (to) => {
|
||||
if (gainNode != null) {
|
||||
gainNode.gain.value = to;
|
||||
} else if (audioEl.value != null) {
|
||||
// グラフを組めなかったときの音量制御は要素側が担当する
|
||||
audioEl.value.volume = to;
|
||||
}
|
||||
});
|
||||
|
||||
watch([bgColor, fgColor], redrawIfStopped);
|
||||
|
||||
watch(() => fileUser.value?.avatarUrl, (avatarUrl) => {
|
||||
const img = new Image();
|
||||
img.addEventListener('load', () => {
|
||||
avatarImage.value = img;
|
||||
redrawIfStopped();
|
||||
}, { once: true });
|
||||
img.src = avatarUrl ?? '/static-assets/avatar.png';
|
||||
}, { immediate: true });
|
||||
|
||||
watch(audioEl, () => {
|
||||
teardown();
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
defineExpose({
|
||||
audioEl,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.visualizer {
|
||||
display: block;
|
||||
user-select: none;
|
||||
// 100cqw / 100cqhの基準はMkLightbox.item.vueの.contentWrapper (= paddingを除いた実際の表示領域)
|
||||
width: min(100cqw, calc(100cqh * 16 / 9));
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--MI_THEME-panel);
|
||||
}
|
||||
|
||||
.playIconWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 100%;
|
||||
font-size: 120%;
|
||||
background: var(--MI_THEME-accent);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
scale: 1;
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
|
||||
// アイコン自体はクリックを受け取らないので、hoverは下のcanvas要素を経由して拾う
|
||||
.visualizer:hover ~ .playIconWrapper .playIcon {
|
||||
scale: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,455 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.seekbar]">
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsCenter]">
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 音量をメディア要素に適用しない(ビジュアライザー用) */
|
||||
externalVolumeControl?: boolean;
|
||||
}>(), {
|
||||
externalVolumeControl: false,
|
||||
});
|
||||
|
||||
const volume = defineModel<number>('volume', { required: true });
|
||||
|
||||
const mediaEl = inject(DI.mkLightboxItemMediaEl, shallowRef<HTMLVideoElement | HTMLAudioElement | null>(null));
|
||||
const isVideo = computed(() => mediaEl.value instanceof HTMLVideoElement);
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled && isVideo.value ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false); // ユーザーが再生中であることを期待する状態か
|
||||
const isActuallyPlaying = ref(false); // 実際に再生中か (バッファリング等で一時停止している場合は false)
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (durationMs.value === 0) return 0;
|
||||
return bufferedEnd.value / (durationMs.value / 1000);
|
||||
});
|
||||
|
||||
// state の更新はすべてメディア要素のイベント側に任せる
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
mediaEl.value?.pause();
|
||||
} else {
|
||||
// 自動再生のブロック等で reject しうるが、再生ボタンが出たままになるだけなので握りつぶす
|
||||
mediaEl.value?.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
// ブラウザ側で許可されていない場合等にrejectしうるが、表示が変わらないだけなので握りつぶす
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to exit picture-in-picture:', err);
|
||||
});
|
||||
} else if (isVideo.value) {
|
||||
(mediaEl.value as HTMLVideoElement).requestPictureInPicture().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to enter picture-in-picture:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let loopObserver: MutationObserver | null = null;
|
||||
|
||||
// currentTime だけは進捗を通知するイベントが timeupdate しかなく、
|
||||
// これは 4Hz 程度でしか発火しないためシークバーがカクつく。
|
||||
// そのため再生中に限り requestAnimationFrame で補間する
|
||||
let elapsedTickFrameId: number | null = null;
|
||||
|
||||
function syncElapsedTime() {
|
||||
if (mediaEl.value == null) return;
|
||||
elapsedTimeMs.value = mediaEl.value.currentTime * 1000;
|
||||
}
|
||||
|
||||
function elapsedTick() {
|
||||
syncElapsedTime();
|
||||
elapsedTickFrameId = window.requestAnimationFrame(elapsedTick);
|
||||
}
|
||||
|
||||
function startElapsedTick() {
|
||||
if (elapsedTickFrameId != null) return;
|
||||
elapsedTickFrameId = window.requestAnimationFrame(elapsedTick);
|
||||
}
|
||||
|
||||
function stopElapsedTick() {
|
||||
if (elapsedTickFrameId == null) return;
|
||||
window.cancelAnimationFrame(elapsedTickFrameId);
|
||||
elapsedTickFrameId = null;
|
||||
}
|
||||
|
||||
function syncDuration() {
|
||||
const duration = mediaEl.value?.duration;
|
||||
// メタデータ読み込み前は NaN、ライブストリームでは Infinity になりうる
|
||||
durationMs.value = duration != null && Number.isFinite(duration) ? duration * 1000 : 0;
|
||||
}
|
||||
|
||||
function syncBuffered() {
|
||||
const buffered = mediaEl.value?.buffered;
|
||||
if (buffered == null || buffered.length === 0) {
|
||||
bufferedEnd.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// シークすると読み込み済みの範囲が複数に分かれるため、最も先まで到達している位置を採用する
|
||||
let end = 0;
|
||||
for (let i = 0; i < buffered.length; i++) {
|
||||
if (buffered.end(i) > end) end = buffered.end(i);
|
||||
}
|
||||
bufferedEnd.value = end;
|
||||
}
|
||||
|
||||
function syncReady() {
|
||||
const el = mediaEl.value;
|
||||
isReady.value = el != null && el.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const el: HTMLMediaElement | null = mediaEl.value;
|
||||
if (el == null) return;
|
||||
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const on = (type: keyof HTMLMediaElementEventMap, listener: () => void) => {
|
||||
el.addEventListener(type, listener, { signal });
|
||||
};
|
||||
|
||||
on('play', () => {
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
startElapsedTick();
|
||||
});
|
||||
|
||||
on('playing', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
startElapsedTick();
|
||||
});
|
||||
|
||||
on('waiting', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
stopElapsedTick();
|
||||
});
|
||||
|
||||
on('pause', () => {
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
stopElapsedTick();
|
||||
});
|
||||
|
||||
on('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
stopElapsedTick();
|
||||
syncElapsedTime();
|
||||
});
|
||||
|
||||
on('timeupdate', syncElapsedTime);
|
||||
on('seeking', syncElapsedTime);
|
||||
on('seeked', () => {
|
||||
syncElapsedTime();
|
||||
syncBuffered();
|
||||
});
|
||||
|
||||
on('durationchange', syncDuration);
|
||||
on('loadstart', syncReady);
|
||||
on('canplay', syncReady);
|
||||
on('canplaythrough', syncReady);
|
||||
on('loadedmetadata', () => {
|
||||
syncDuration();
|
||||
syncBuffered();
|
||||
syncReady();
|
||||
});
|
||||
on('progress', syncBuffered);
|
||||
on('emptied', () => {
|
||||
isReady.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
oncePlayed.value = false;
|
||||
stopElapsedTick();
|
||||
syncDuration();
|
||||
syncBuffered();
|
||||
syncElapsedTime();
|
||||
});
|
||||
|
||||
// ネイティブUIやブラウザのコンテキストメニューから変更されうるもの
|
||||
// (externalVolumeControl時は要素の音量を100%に固定しているので、取り込むと表示が壊れる)
|
||||
if (!props.externalVolumeControl) {
|
||||
on('volumechange', () => {
|
||||
const to = el.muted ? 0 : el.volume;
|
||||
if (volume.value !== to) volume.value = to;
|
||||
});
|
||||
}
|
||||
|
||||
on('ratechange', () => {
|
||||
if (speed.value !== el.playbackRate) speed.value = el.playbackRate;
|
||||
});
|
||||
|
||||
// loop には変更イベントが無いが、属性の変化を監視すればネイティブUI経由の変更も拾える
|
||||
loopObserver = new MutationObserver(() => {
|
||||
if (loop.value !== el.loop) loop.value = el.loop;
|
||||
});
|
||||
loopObserver.observe(el, { attributes: true, attributeFilter: ['loop'] });
|
||||
|
||||
// 現在の要素の状態を state に取り込む
|
||||
// (コントロール表示前に再生が始まっている場合等)
|
||||
syncReady();
|
||||
syncDuration();
|
||||
syncBuffered();
|
||||
syncElapsedTime();
|
||||
loop.value = el.loop;
|
||||
speed.value = el.playbackRate;
|
||||
isPlaying.value = !el.paused;
|
||||
if (!el.paused) {
|
||||
oncePlayed.value = true;
|
||||
startElapsedTick();
|
||||
}
|
||||
|
||||
if (!props.externalVolumeControl) {
|
||||
el.volume = volume.value;
|
||||
}
|
||||
|
||||
// 音声トラックを持たない動画はGIFのように扱う
|
||||
if (isVideo.value) {
|
||||
hasAudio(el).then(had => {
|
||||
// 判定を待っている間に teardown / 再 init されている可能性があるので、世代が変わっていたら何もしない
|
||||
if (signal.aborted) return;
|
||||
if (!had) {
|
||||
el.loop = el.muted = true;
|
||||
el.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
abortController?.abort();
|
||||
abortController = null;
|
||||
loopObserver?.disconnect();
|
||||
loopObserver = null;
|
||||
stopElapsedTick();
|
||||
isReady.value = false;
|
||||
// メディア要素を差し替えた場合、古い要素のイベントはもう届かないのでここで戻しておく
|
||||
// (isPlaying / loop / speed 等は init() が新しい要素から取り込み直す)
|
||||
isActuallyPlaying.value = false;
|
||||
oncePlayed.value = false;
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (props.externalVolumeControl) return; // 適用は音量を受け取った側 (Web Audio経路) が行う
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.volume = to;
|
||||
mediaEl.value.muted = to === 0;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (mediaEl.value == null) return;
|
||||
mediaEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(mediaEl, () => {
|
||||
teardown();
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
defineExpose({
|
||||
isPlaying,
|
||||
isReady,
|
||||
isActuallyPlaying,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"seekbar seekbar seekbar"
|
||||
"left center right";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
grid-area: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
</style>
|
||||
@@ -55,7 +55,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="[$style.hiddenText, { [$style.withBlur]: content.type === 'video' && content.thumbnailUrl != null }]">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b v-if="content.file?.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
|
||||
<b v-else style="display: block;"><i class="ti" :class="content.type === 'image' ? 'ti-photo' : 'ti-movie'"></i> {{ content.type === 'image' ? i18n.ts.image : i18n.ts.video }}</b>
|
||||
<b v-else style="display: block;"><i class="ti" :class="contentHideFileIcon"></i> {{ contentHideFileText }}</b>
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<video
|
||||
v-else-if="content.type === 'video'"
|
||||
ref="videoEl"
|
||||
data-gallery-click-action="video"
|
||||
data-gallery-click-action="media"
|
||||
:class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
|
||||
:src="content.url"
|
||||
:alt="content.file?.comment ?? undefined"
|
||||
@@ -90,16 +90,36 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
|
||||
playsinline
|
||||
@loadedmetadata="onVideoLoadedMetadata"
|
||||
@click.stop="onVideoClick"
|
||||
@click.stop="onMediaClick"
|
||||
></video>
|
||||
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
|
||||
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isMediaPlaying" :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="content.type === 'audio' && prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.audioRoot">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
:src="content.url"
|
||||
:alt="content.file?.comment ?? undefined"
|
||||
:class="$style.audio"
|
||||
controls
|
||||
@loadedmetadata="originalContentLoaded = true"
|
||||
></audio>
|
||||
</div>
|
||||
<XAudioVisualizer
|
||||
v-else-if="content.type === 'audio' && !prefer.s.useNativeUiForVideoAudioPlayer"
|
||||
ref="audioVisualizer"
|
||||
:content="content"
|
||||
:user="user"
|
||||
:isPlaying="isMediaPlaying"
|
||||
:volume="volume"
|
||||
@click.stop="onMediaClick"
|
||||
@loadedmetadata="originalContentLoaded = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="activated && (!originalContentLoaded || (content.type === 'video' && isVideoPlaying && !isVideoActuallyPlaying))" :class="$style.loading">
|
||||
<div v-if="activated && (!originalContentLoaded || (isMediaControlledByMisskey && isMediaPlaying && (!isMediaReady || !isMediaActuallyPlaying)))" :class="$style.loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,8 +138,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
|
||||
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
|
||||
<div v-if="content.type === 'video' && !hide && !prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.mediaControl">
|
||||
<MkVideoControl v-if="videoEl != null" ref="videoControl" :videoEl="videoEl"/>
|
||||
<div v-if="isMediaControlledByMisskey && !hide" :class="$style.mediaControl">
|
||||
<XControl v-if="mediaEl != null" ref="mediaControl" v-model:volume="volume" :externalVolumeControl="isVolumeHandledByVisualizer"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,7 +160,7 @@ type Rect = Size & {
|
||||
|
||||
export type Content = {
|
||||
id: string;
|
||||
type: 'image' | 'video';
|
||||
type: 'image' | 'video' | 'audio';
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
width?: number | null;
|
||||
@@ -177,9 +197,10 @@ export function calculateSourceTransform({
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount } from 'vue';
|
||||
import MkVideoControl from '@/components/MkVideoControl.vue';
|
||||
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide, onBeforeUnmount, defineAsyncComponent } from 'vue';
|
||||
import MkBlurhash from '@/components/MkBlurhash.vue';
|
||||
import XControl from './MkLightbox.item.controls.vue';
|
||||
import type XAudioVisualizer__TypeReferenceOnly from './MkLightbox.item.audio-visualizer.vue';
|
||||
import XFileInfo from './MkLightbox.item.fileinfo.vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
@@ -194,6 +215,7 @@ import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: Content;
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
activated: boolean;
|
||||
initiallyOpened?: boolean;
|
||||
}>(), {
|
||||
@@ -208,6 +230,8 @@ const emit = defineEmits<{
|
||||
(ev: 'cancelHorizontalSwipe'): void;
|
||||
}>();
|
||||
|
||||
const XAudioVisualizer = defineAsyncComponent(() => import('./MkLightbox.item.audio-visualizer.vue'));
|
||||
|
||||
// 一回のビューワー操作内では状態を維持するためにmodelで親に伝えて親で状態を保持する
|
||||
// TODO: drivefileのproperties側にピクセルアートかどうかのフラグを立ててそちらを元にデフォルトの挙動を決めるようにする
|
||||
const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
|
||||
@@ -215,19 +239,65 @@ const pixelatedZoom = defineModel<boolean>('pixelatedZoom', { required: true });
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const mainEl = useTemplateRef('mainEl');
|
||||
const videoEl = useTemplateRef('videoEl');
|
||||
const videoControl = useTemplateRef('videoControl');
|
||||
const audioEl = useTemplateRef('audioEl'); // ネイティブUI時
|
||||
const audioVisualizer = useTemplateRef<InstanceType<typeof XAudioVisualizer__TypeReferenceOnly>>('audioVisualizer'); // ネイティブUIじゃない場合
|
||||
const mediaControl = useTemplateRef<InstanceType<typeof XControl>>('mediaControl');
|
||||
|
||||
provide(DI.mkLightboxItemVideoEl, videoEl);
|
||||
const mediaEl = computed<HTMLVideoElement | HTMLAudioElement | null>(() => {
|
||||
if (props.content.type === 'video') {
|
||||
return videoEl.value;
|
||||
} else if (props.content.type === 'audio') {
|
||||
if (prefer.s.useNativeUiForVideoAudioPlayer) {
|
||||
return audioEl.value;
|
||||
} else {
|
||||
return audioVisualizer.value?.audioEl ?? null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
provide(DI.mkLightboxItemMediaEl, mediaEl);
|
||||
|
||||
const originalContentLoaded = ref(false);
|
||||
const thumbnailContentLoaded = ref(false);
|
||||
const enableTransition = ref(false);
|
||||
const infoShowing = ref(false);
|
||||
const hide = ref(true);
|
||||
const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
|
||||
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
|
||||
const isMediaControlledByMisskey = computed(() => ['video', 'audio'].includes(props.content.type) && !prefer.s.useNativeUiForVideoAudioPlayer);
|
||||
// ビジュアライザー使用時は音量の適用をGainNode側が担当する (メディア要素は100%固定にして、波形が音量レベルに依存しないようにするため)
|
||||
const isVolumeHandledByVisualizer = computed(() => props.content.type === 'audio' && !prefer.s.useNativeUiForVideoAudioPlayer);
|
||||
const volume = ref(0.25);
|
||||
const isMediaReady = computed(() => mediaControl.value?.isReady ?? false);
|
||||
const isMediaPlaying = computed(() => mediaControl.value?.isPlaying ?? false);
|
||||
const isMediaActuallyPlaying = computed(() => mediaControl.value?.isActuallyPlaying ?? false);
|
||||
let canOpenAnimation = false;
|
||||
|
||||
const contentHideFileIcon = computed(() => {
|
||||
switch (props.content.type) {
|
||||
case 'image':
|
||||
return 'ti-photo';
|
||||
case 'video':
|
||||
return 'ti-movie';
|
||||
case 'audio':
|
||||
return 'ti-music';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const contentHideFileText = computed(() => {
|
||||
switch (props.content.type) {
|
||||
case 'image':
|
||||
return i18n.ts.image;
|
||||
case 'video':
|
||||
return i18n.ts.video;
|
||||
case 'audio':
|
||||
return i18n.ts.audio;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const videoAspectRatio = ref<number | null>(
|
||||
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
|
||||
? props.content.width / props.content.height
|
||||
@@ -244,7 +314,7 @@ function onVideoLoadedMetadata() {
|
||||
}
|
||||
|
||||
const headerSize = 30;
|
||||
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
|
||||
const footerSize = isMediaControlledByMisskey.value ? 80 : 0;
|
||||
|
||||
const padding = deviceKind === 'smartphone' ? {
|
||||
top: Math.max(0, headerSize + 10),
|
||||
@@ -483,7 +553,7 @@ const VELOCITY_WINDOW = 100;
|
||||
|
||||
let isDragging = false;
|
||||
let isClick = false;
|
||||
let clickAction: 'hidden' | 'video' | null = null;
|
||||
let clickAction: 'hidden' | 'media' | null = null;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let currentPointerId: number | null = null;
|
||||
@@ -527,11 +597,11 @@ function getVelocity(now: number): { x: number; y: number } {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClickAction(target: EventTarget | null): 'hidden' | 'video' | null {
|
||||
function resolveClickAction(target: EventTarget | null): 'hidden' | 'media' | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
|
||||
const action = target.closest('[data-gallery-click-action]')?.getAttribute('data-gallery-click-action');
|
||||
if (action === 'hidden' || action === 'video') {
|
||||
if (action === 'hidden' || action === 'media') {
|
||||
return action;
|
||||
}
|
||||
|
||||
@@ -831,8 +901,8 @@ function onClick(ev: MouseEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'video') {
|
||||
onVideoClick();
|
||||
if (action === 'media') {
|
||||
onMediaClick();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -846,25 +916,49 @@ function onClick(ev: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* play() は自動再生のブロックや、再生が始まる前の pause() によって reject することがある。
|
||||
* いずれも再生ボタンが出たままになるだけで復帰不能ではないので、握りつぶす
|
||||
*/
|
||||
function safePlay(el: HTMLMediaElement) {
|
||||
el.play().catch(err => {
|
||||
if (_DEV_) console.warn('Failed to play media:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function playWhenAvailable() {
|
||||
if (mediaEl.value != null) {
|
||||
safePlay(mediaEl.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// オーディオビジュアライザはlazy-loadのため、この時点ではまだ要素が無い可能性がある
|
||||
const watchStop = watch(mediaEl, (newMediaEl) => {
|
||||
if (newMediaEl == null) return;
|
||||
safePlay(newMediaEl);
|
||||
watchStop();
|
||||
});
|
||||
}
|
||||
|
||||
async function onHiddenClick() {
|
||||
if (hide.value) {
|
||||
if (props.content.file == null || await canRevealFile(props.content.file)) {
|
||||
hide.value = false;
|
||||
if (props.content.type === 'video' && videoEl.value != null) {
|
||||
videoEl.value.play();
|
||||
if (['audio', 'video'].includes(props.content.type)) {
|
||||
playWhenAvailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoClick() {
|
||||
function onMediaClick() {
|
||||
if (!prefer.s.useNativeUiForVideoAudioPlayer) {
|
||||
if (videoEl.value == null) return;
|
||||
if (mediaEl.value == null) return;
|
||||
|
||||
if (videoEl.value.paused) {
|
||||
videoEl.value.play();
|
||||
if (mediaEl.value.paused) {
|
||||
safePlay(mediaEl.value);
|
||||
} else {
|
||||
videoEl.value.pause();
|
||||
mediaEl.value.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -883,12 +977,16 @@ function openMenu(ev: PointerEvent) {
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
if (props.content.type === 'image') {
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.pixelatedZoom,
|
||||
icon: 'ti ti-grain',
|
||||
ref: pixelatedZoom,
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.pixelatedZoom,
|
||||
icon: 'ti ti-grain',
|
||||
ref: pixelatedZoom,
|
||||
}, {
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
@@ -908,9 +1006,7 @@ function openMenu(ev: PointerEvent) {
|
||||
}
|
||||
|
||||
function onActive() {
|
||||
if (videoEl.value != null) {
|
||||
videoEl.value.play();
|
||||
}
|
||||
playWhenAvailable();
|
||||
}
|
||||
|
||||
function onDeactive() {
|
||||
@@ -918,8 +1014,8 @@ function onDeactive() {
|
||||
isZooming.value = false;
|
||||
resetToNeutral();
|
||||
}
|
||||
if (videoEl.value != null && props.activated) {
|
||||
videoEl.value.pause();
|
||||
if (mediaEl.value != null && props.activated) {
|
||||
mediaEl.value.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,6 +1084,20 @@ defineExpose({
|
||||
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
|
||||
}
|
||||
|
||||
.audioRoot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: calc(100vw - 140px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -30,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
|
||||
v-model:pixelatedZoom="pixelatedZoom"
|
||||
:content="content"
|
||||
:user="user"
|
||||
:initiallyOpened="i === (props.defaultIndex ?? 0)"
|
||||
:activated="activatedIndexes.has(i)"
|
||||
@close="onItemClose"
|
||||
@@ -50,6 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XItem from './MkLightbox.item.vue';
|
||||
import type { Content } from './MkLightbox.item.vue';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
@@ -61,6 +63,7 @@ import { focusTrap } from '@/utility/focus-trap.js';
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultIndex?: number;
|
||||
contents: Content[];
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
}>(), {
|
||||
});
|
||||
|
||||
|
||||
@@ -6,14 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
:class="[
|
||||
$style.audioContainer,
|
||||
$style.root,
|
||||
(audio.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
|
||||
]"
|
||||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="reveal">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
@@ -22,140 +19,84 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-else-if="prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.nativeAudioContainer">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
controls
|
||||
:class="$style.nativeAudio"
|
||||
@keydown.prevent
|
||||
>
|
||||
<source :src="audio.url">
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div v-else :class="$style.audioControls">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<source :src="audio.url">
|
||||
</audio>
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
v-panel
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:class="$style.audioRoot"
|
||||
@click="emit('mediaClick', $event)"
|
||||
@keydown.enter.prevent="emit('mediaClick', $event)"
|
||||
@keydown.space.prevent="emit('mediaClick', $event)"
|
||||
>
|
||||
<div :class="$style.audioRootSkelton"></div>
|
||||
<svg version="1.1" viewBox="0 0 2911.5 735.42" xmlns="http://www.w3.org/2000/svg" :class="$style.audioWave">
|
||||
<path d="m2852.4 363.63h-21.65v8.17h21.65zm29.53 0h-21.65v8.17h21.65zm29.53 0h-21.65v8.17h21.65z"/>
|
||||
<path d="m1966.5 371.8h-21.65v-8.17h21.65zm29.53-45.33h-21.65v82.48h21.65zm29.53-51.19h-21.65v184.86h21.65z"/>
|
||||
<path d="m2468.6 363.63h-21.65v8.17h21.65zm-29.53-158.13h-21.65v324.42h21.65zm-29.53-85.62h-21.65v495.67h21.65z"/>
|
||||
<path d="m2320.9 52.6h-21.65v630.22h21.65zm59.06 0h-21.65v630.22h21.65zm-29.53-52.6h-21.65v735.42h21.65v-735.41z"/>
|
||||
<path d="m2498.1 320.48h-21.65v94.47h21.65zm59.06-200.6h-21.65v495.67h21.65zm-29.53 114.98h-21.65v265.71h21.65z"/>
|
||||
<path d="m2084.7 234.86h-21.65v265.71h21.65zm29.53-89.29h-21.65v444.3h21.65zm-59.06 71.31h-21.65v301.67h21.65z"/>
|
||||
<path d="m2202.8 363.63h-21.65v8.17h21.65zm-59.06-139.94h-21.65v288.05h21.65zm29.53 87.58h-21.65v112.9h21.65z"/>
|
||||
<path d="m2645.7 262.99h-21.65v209.44h21.65zm-59.06-117.43h-21.65v444.3h21.65v-444.29zm29.53 149.23h-21.65v145.84h21.65z"/>
|
||||
<path d="m2704.8 262.99h-21.65v209.44h21.65zm-29.53-28.13h-21.65v265.71h21.65zm59.06 59.94h-21.65v145.84h21.65z"/>
|
||||
<path d="m2822.9 349.18h-21.65v37.07h21.65zm-29.53-28.7h-21.65v94.47h21.65zm-29.53-25.69h-21.65v145.84h21.65z"/>
|
||||
<path d="m2261.8 171.25h-21.65v392.92h21.65zm29.53-51.37h-21.65v495.67h21.65zm-59.06 174.92h-21.65v145.84h21.65z"/>
|
||||
<path d="m297.93 374.56h-22.45v-8.48h22.45zm30.61-8.48h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45z"/>
|
||||
<path d="m940.74 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45z"/>
|
||||
<path d="m512.2 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm-61.22-62.42h-22.44v133.32h22.44z"/>
|
||||
<path d="m573.42 366.09h-22.45v8.48h22.45zm30.61 0h-22.45v8.48h22.45zm30.61-62.42h-22.44v133.32h22.44z"/>
|
||||
<path d="m114.27 222.53h-22.44v295.59h22.44zm61.22 0h-22.44v295.59h22.44zm-30.61-39.7h-22.44v375h22.44v-375.01z"/>
|
||||
<path d="m818.29 222.53h-22.44v295.59h22.44zm-61.22-129.47h-22.44v554.53h22.44zm30.61-36.25h-22.44v627.03h22.44z"/>
|
||||
<path d="m22.44 20.55h-22.44v699.54h22.44zm30.61 36.25h-22.44v627.04h22.44v-627.03zm30.61 210.61h-22.44v205.82h22.44z"/>
|
||||
<path d="m267.31 303.66h-22.44v133.32h22.44zm-61.22-36.25h-22.44v205.82h22.44zm30.61 0h-22.44v205.82h22.44z"/>
|
||||
<path d="m389.75 303.66h-22.44v133.32h22.44zm30.61 0h-22.44v133.32h22.44zm30.61-36.25h-22.44v205.82h22.44z"/>
|
||||
<path d="m726.46 141.39h-22.44v457.86h22.44zm-30.61 41.43h-22.44v375h22.44zm-30.61 84.59h-22.44v205.82h22.44z"/>
|
||||
<path d="m910.12 222.53h-22.44v295.59h22.44zm-61.22-39.7h-22.44v375h22.44v-375.01zm30.61 84.59h-22.44v205.82h22.44z"/>
|
||||
<path d="m1286.3 524.19h-19.72v-307.49h19.72zm-30.59-388.64h-19.72v469.79h19.72zm-30.59-63.76h-19.72v597.32h19.72z"/>
|
||||
<path d="m1133.4 605.34h-19.72v-469.79h19.72zm30.59-533.55h-19.72v597.32h19.72zm30.59-49.85h-19.72v697.02h19.72z"/>
|
||||
<path d="m1500.5 469.7h-19.72v-198.51h19.72zm61.18-198.51h-19.72v198.51h19.72zm-30.59-26.66h-19.72v251.84h19.72z"/>
|
||||
<path d="m1408.7 605.34h-19.72v-469.79h19.72zm30.59-445.45h-19.72v421.1h19.72v-421.09zm30.59 141.44h-19.72v138.23h19.72z"/>
|
||||
<path d="m1653.4 415.21h-19.72v-89.53h19.72zm-61.18-113.88h-19.72v138.23h19.72zm30.59 0h-19.72v138.23h19.72z"/>
|
||||
<path d="m1775.8 415.21h-19.72v-89.53h19.72zm30.59-89.53h-19.72v89.53h19.72zm30.59-24.35h-19.72v138.23h19.72z"/>
|
||||
<path d="m1102.8 556.65h-19.72v-372.41h19.72zm-30.59-255.32h-19.72v138.23h19.72zm-30.59 64.89h-19.72v8.45h19.72z"/>
|
||||
<path d="m1347.5 415.21h-19.72v-89.53h19.72zm30.59-170.69h-19.72v251.84h19.72v-251.83zm-61.18 121.7h-19.72v8.45h19.72z"/>
|
||||
<path d="m1684 374.67h-19.72v-8.45h19.72zm30.59-8.45h-19.72v8.45h19.72zm30.59 0h-19.72v8.45h19.72z"/>
|
||||
<path d="m1867.6 415.21h-19.72v-89.53h19.72zm61.18-48.99h-19.72v8.45h19.72zm-30.59 0h-19.72v8.45h19.72z"/>
|
||||
</svg>
|
||||
<div :class="$style.audioText">
|
||||
<i class="ti ti-music"></i> {{ i18n.ts.audio }}
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="() => {}"
|
||||
@mousedown.prevent.stop="showMenu"
|
||||
>
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
<div :class="$style.playIconWrapper">
|
||||
<div :class="$style.playIcon">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsVolume]">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="toggleMute"
|
||||
>
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
</div>
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:class="$style.seekbarRoot"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
<button :class="[$style.menu, $style.menuBottom]" class="_button" @click.stop="showMenu" @keydown.stop><i class="ti ti-dots" style="vertical-align: middle;" aria-hidden="true"></i></button>
|
||||
<button :class="[$style.menu, $style.menuTop]" class="_button" @click.stop="hide = true" @keydown.stop><i class="ti ti-eye-off" style="vertical-align: middle;" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTemplateRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { Keymap } from '@/utility/hotkey.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import * as os from '@/os.js';
|
||||
import { getFileMenu } from '@/utility/get-file-menu.js';
|
||||
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
audio: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const keymap = {
|
||||
'up': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
'down': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'left': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'right': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||
}
|
||||
},
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
if (!playerEl.value) return false;
|
||||
return playerEl.value === window.document.activeElement || playerEl.value.contains(window.document.activeElement);
|
||||
}
|
||||
|
||||
const playerEl = useTemplateRef('playerEl');
|
||||
const audioEl = useTemplateRef('audioEl');
|
||||
const emit = defineEmits<{
|
||||
(event: 'mediaClick', ev: PointerEvent | KeyboardEvent): void;
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref(shouldHideFileByDefault(props.audio));
|
||||
|
||||
async function reveal() {
|
||||
@@ -166,219 +107,30 @@ async function reveal() {
|
||||
hide.value = false;
|
||||
}
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
|
||||
menu.push(...getFileMenu(props.audio, (newState) => {
|
||||
hide.value = newState;
|
||||
}));
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
function showMenu(ev: PointerEvent) {
|
||||
os.popupMenu(getFileMenu(props.audio, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (!audioEl.value) return;
|
||||
audioEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (!audioEl.value) return 0;
|
||||
return bufferedEnd.value / audioEl.value.duration;
|
||||
});
|
||||
|
||||
// MediaControl Events
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value || !audioEl.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioEl.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
audioEl.value.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getFileMenu(props.audio, (newHide) => { hide.value = newHide; }), ev);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let onceInit = false;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
let stopAudioElWatch: () => void;
|
||||
|
||||
function init() {
|
||||
if (onceInit) return;
|
||||
onceInit = true;
|
||||
|
||||
stopAudioElWatch = watch(audioEl, () => {
|
||||
if (audioEl.value) {
|
||||
isReady.value = true;
|
||||
|
||||
function updateMediaTick() {
|
||||
if (audioEl.value) {
|
||||
try {
|
||||
bufferedEnd.value = audioEl.value.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = audioEl.value.currentTime * 1000;
|
||||
|
||||
if (audioEl.value.loop !== loop.value) {
|
||||
loop.value = audioEl.value.loop;
|
||||
}
|
||||
}
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
audioEl.value.addEventListener('play', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
});
|
||||
|
||||
audioEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
audioEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
durationMs.value = audioEl.value.duration * 1000;
|
||||
audioEl.value.addEventListener('durationchange', () => {
|
||||
if (audioEl.value) {
|
||||
durationMs.value = audioEl.value.duration * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
audioEl.value.volume = volume.value;
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (audioEl.value) audioEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (audioEl.value) audioEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (audioEl.value) audioEl.value.loop = to;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isReady.value = false;
|
||||
isPlaying.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
elapsedTimeMs.value = 0;
|
||||
durationMs.value = 0;
|
||||
bufferedEnd.value = 0;
|
||||
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore');
|
||||
stopAudioElWatch();
|
||||
onceInit = false;
|
||||
if (mediaTickFrameId) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
mediaTickFrameId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.audioContainer {
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
border: .5px solid var(--MI_THEME-divider);
|
||||
border-radius: var(--MI-radius);
|
||||
overflow: clip;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.playIcon {
|
||||
scale: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
@@ -399,6 +151,7 @@ onDeactivated(() => {
|
||||
|
||||
.hidden {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -417,86 +170,85 @@ onDeactivated(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"left time . volume right"
|
||||
"seekbar seekbar seekbar seekbar seekbar";
|
||||
grid-template-columns: auto auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
font-size: 1.05rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--MI_THEME-accent);
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
grid-area: time;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
grid-area: volume;
|
||||
|
||||
.volumeSeekbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.seekbarRoot {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.audioControls {
|
||||
grid-template-areas: "left seekbar time volume right";
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
.volumeSeekbar {
|
||||
max-width: 90px;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nativeAudioContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.nativeAudio {
|
||||
display: block;
|
||||
.audioRoot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.audioRootSkelton {
|
||||
position: relative;
|
||||
width: min(100cqw, calc(100cqh * 16 / 9));
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audioWave {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
fill: color(from var(--MI_THEME-fg) srgb r g b / 0.5);
|
||||
}
|
||||
|
||||
.audioText {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
font-size: 0.85em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIconWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 100%;
|
||||
font-size: 120%;
|
||||
background: var(--MI_THEME-accent);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
scale: 1;
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menuBottom {
|
||||
border-radius: 8px 0 8px 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.menuTop {
|
||||
border-radius: 0 8px 0 8px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
|
||||
<div v-else-if="hide" :class="$style.sensitive" @click="reveal">
|
||||
<div v-if="hide" :class="$style.sensitive" @click="reveal">
|
||||
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
@@ -27,7 +26,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkMediaAudio from '@/components/MkMediaAudio.vue';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
|
||||
<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
|
||||
<XBanner v-for="media in medias.nonPreviewable" :key="media.id" :media="media"/>
|
||||
<div v-if="count > 0" :class="$style.container">
|
||||
<div
|
||||
ref="gallery"
|
||||
:class="[
|
||||
@@ -19,7 +19,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
}] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
|
||||
]"
|
||||
>
|
||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||
<template v-for="media in medias.previewable">
|
||||
<XAudio
|
||||
v-if="media.type.startsWith('audio')"
|
||||
:key="`audio:${media.id}`"
|
||||
:class="$style.media"
|
||||
:audio="media"
|
||||
@mediaClick="onMediaClick(media)"
|
||||
/>
|
||||
<XVideo
|
||||
v-if="media.type.startsWith('video')"
|
||||
:key="`video:${media.id}`"
|
||||
@@ -44,24 +51,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, markRaw, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { computed, markRaw, onMounted, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import XBanner from '@/components/MkMediaBanner.vue';
|
||||
import XAudio from '@/components/MkMediaAudio.vue';
|
||||
import XImage from '@/components/MkMediaImage.vue';
|
||||
import XVideo from '@/components/MkMediaVideo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
user?: Misskey.entities.User | null; // DriveFileのuserはnullになることがある。その場合に使用する所有者情報
|
||||
raw?: boolean;
|
||||
}>();
|
||||
|
||||
const gallery = useTemplateRef('gallery');
|
||||
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||
const medias = computed(() => {
|
||||
const previewable: Misskey.entities.DriveFile[] = [];
|
||||
const nonPreviewable: Misskey.entities.DriveFile[] = [];
|
||||
for (const file of props.mediaList) {
|
||||
if (isPreviewable(file.type)) {
|
||||
previewable.push(file);
|
||||
} else {
|
||||
nonPreviewable.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previewable,
|
||||
nonPreviewable,
|
||||
};
|
||||
});
|
||||
const count = computed(() => medias.value.previewable.length);
|
||||
const markerId = genId();
|
||||
|
||||
async function calcAspectRatio() {
|
||||
@@ -101,15 +126,6 @@ onMounted(() => {
|
||||
if (gallery.value == null) return; // TSを黙らすため
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
});
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
|
||||
function onMediaClick(file: Misskey.entities.DriveFile) {
|
||||
if (prefer.s.imageNewTab) {
|
||||
window.open(file.url, '_blank');
|
||||
@@ -120,7 +136,7 @@ function onMediaClick(file: Misskey.entities.DriveFile) {
|
||||
|
||||
async function openGallery(id?: string) {
|
||||
if (id == null) {
|
||||
const firstImage = props.mediaList.find(media => previewable(media));
|
||||
const firstImage = medias.value.previewable[0];
|
||||
if (firstImage == null) return;
|
||||
id = firstImage.id;
|
||||
}
|
||||
@@ -132,9 +148,9 @@ async function openGallery(id?: string) {
|
||||
return markRaw(found);
|
||||
};
|
||||
|
||||
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
|
||||
const contents = medias.value.previewable.map<Content>(media => ({
|
||||
id: media.id,
|
||||
type: media.type.startsWith('video') ? 'video' : 'image',
|
||||
type: getType(media.type),
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.properties.width,
|
||||
@@ -147,6 +163,7 @@ async function openGallery(id?: string) {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkLightbox.vue').then(x => x.default), {
|
||||
defaultIndex: contents.findIndex(conten => conten.id === id),
|
||||
contents: contents,
|
||||
user: props.user,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0" style="margin-top: 8px;">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :user="appearNote.user"/>
|
||||
</div>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
@@ -357,7 +357,7 @@ const keymap = {
|
||||
renoteCollapsed.value = false;
|
||||
} else if (appearNote.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
} else if (isLong.value) {
|
||||
} else if (isLong) {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" :user="appearNote.user"/>
|
||||
</div>
|
||||
<MkPoll
|
||||
v-if="appearNote.poll"
|
||||
|
||||
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="_selectable">
|
||||
<XValue :value="value" :collapsed="false"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,8 @@ import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Misskey.entities.DriveFile[];
|
||||
@@ -152,9 +154,6 @@ async function describe(file: Misskey.entities.DriveFile) {
|
||||
function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | KeyboardEvent): void {
|
||||
if (menuShowing) return;
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
@@ -171,14 +170,14 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: PointerEvent | Keybo
|
||||
action: () => { describe(file); },
|
||||
});
|
||||
|
||||
if (isImage || isVideo) {
|
||||
if (isPreviewable(file.type)) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: async () => {
|
||||
const constents = props.modelValue.filter(item => item.type.startsWith('image') || item.type.startsWith('video')).map(item => ({
|
||||
const constents = props.modelValue.filter(item => isPreviewable(item.type)).map<Content>(item => ({
|
||||
id: item.id,
|
||||
type: item.type.startsWith('video') ? 'video' as const : 'image' as const,
|
||||
type: getType(item.type),
|
||||
url: item.url,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
width: item.properties.width,
|
||||
|
||||
@@ -109,7 +109,7 @@ const height =
|
||||
const focus = () => container.value?.focus();
|
||||
|
||||
// このコンポーネントが作成された時、非表示状態である場合がある
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
|
||||
// 非表示状態だと要素の幅などは0になってしまうので、ResizeObserverでサイズの変化を監視して計算する
|
||||
const updatePadding = (entries: ResizeObserverEntry[]) => {
|
||||
if (inputEl.value == null) return;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<details v-if="note.files && note.files.length > 0">
|
||||
<summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
|
||||
<MkMediaList :mediaList="note.files"/>
|
||||
<MkMediaList :mediaList="note.files" :user="note.user"/>
|
||||
</details>
|
||||
<details v-if="note.poll">
|
||||
<summary>{{ i18n.ts.poll }}</summary>
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { UploaderItem } from '@/composables/use-uploader.js';
|
||||
import { getUploadName } from '@/composables/use-uploader.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import * as os from '@/os.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
@@ -101,12 +102,12 @@ function onContextmenu(item: UploaderItem, ev: PointerEvent) {
|
||||
}
|
||||
|
||||
async function onThumbnailClick(item: UploaderItem, ev: PointerEvent) {
|
||||
if (item.file.type.startsWith('image') || item.file.type.startsWith('video')) {
|
||||
if (isPreviewable(item.file.type)) {
|
||||
const contents = props.items
|
||||
.filter(item => item.file.type.startsWith('image') || item.file.type.startsWith('video'))
|
||||
.filter(item => isPreviewable(item.file.type))
|
||||
.map<Content>(item => ({
|
||||
id: item.id,
|
||||
type: (item.file.type.startsWith('video') ? 'video' : 'image'),
|
||||
type: getType(item.file.type),
|
||||
url: item.objectUrl,
|
||||
thumbnailUrl: item.thumbnail,
|
||||
filename: getUploadName(item),
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.seekbar]">
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause"></i>
|
||||
<i v-else class="ti ti-player-play"></i>
|
||||
</button>
|
||||
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }} / {{ hms(durationMs) }}</div>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsCenter]">
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
||||
const videoEl = inject(DI.mkLightboxItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
if (videoEl.value == null || videoEl.value.duration === 0) return 0;
|
||||
return bufferedEnd.value / videoEl.value.duration;
|
||||
});
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoEl.value?.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
videoEl.value?.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture();
|
||||
} else {
|
||||
videoEl.value?.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
|
||||
function init() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
isReady.value = true;
|
||||
abortController = new AbortController();
|
||||
|
||||
function updateMediaTick() {
|
||||
if (videoEl.value == null) return;
|
||||
|
||||
try {
|
||||
bufferedEnd.value = videoEl.value.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
|
||||
|
||||
if (videoEl.value.loop !== loop.value) {
|
||||
loop.value = videoEl.value.loop;
|
||||
}
|
||||
|
||||
if (videoEl.value.paused !== !isPlaying.value) {
|
||||
isPlaying.value = !videoEl.value.paused;
|
||||
}
|
||||
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
videoEl.value.addEventListener('waiting', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('playing', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
durationMs.value = videoEl.value.duration * 1000;
|
||||
videoEl.value.addEventListener('durationchange', () => {
|
||||
durationMs.value = videoEl.value!.duration * 1000;
|
||||
}, { signal: abortController.signal });
|
||||
|
||||
videoEl.value.volume = volume.value;
|
||||
hasAudio(videoEl.value).then(had => {
|
||||
if (!had) {
|
||||
videoEl.value!.loop = videoEl.value!.muted = true;
|
||||
videoEl.value!.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
if (videoEl.value == null) return;
|
||||
videoEl.value.loop = to;
|
||||
});
|
||||
|
||||
watch(videoEl, () => {
|
||||
if (abortController != null) {
|
||||
abortController.abort();
|
||||
}
|
||||
if (mediaTickFrameId != null) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
}
|
||||
init();
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mediaTickFrameId != null) {
|
||||
window.cancelAnimationFrame(mediaTickFrameId);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
isPlaying,
|
||||
isActuallyPlaying,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"seekbar seekbar seekbar"
|
||||
"left center right";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsCenter {
|
||||
grid-area: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.seekbar {
|
||||
grid-area: seekbar;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/>
|
||||
<MkMediaList v-if="image" :mediaList="[image]" :user="page.user" :class="$style.mediaList"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
@@ -37,6 +37,7 @@ import { notePage } from '@/filters/note.js';
|
||||
import type { DI as DIType } from '@/di.js';
|
||||
import type { ExtractInjectedType } from '@/types/misc.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { WordMuteResult } from '@/utility/check-word-mute.js';
|
||||
|
||||
export interface UseNoteProps {
|
||||
note: Misskey.entities.Note;
|
||||
@@ -62,36 +63,34 @@ export interface UseNoteOptions {
|
||||
currentAntenna?: Ref<Misskey.entities.Antenna | null> | null;
|
||||
}
|
||||
|
||||
export function calculateMuteStatus<
|
||||
CheckOnly extends boolean,
|
||||
CheckForSensitiveMedia extends boolean,
|
||||
ReturnTypeA = CheckOnly extends true ? boolean : Array<string | string[]> | false | 'sensitiveMute',
|
||||
ReturnTypeB = CheckForSensitiveMedia extends true ? ReturnTypeA : Exclude<ReturnTypeA, 'sensitiveMute'>,
|
||||
>(
|
||||
export function checkNoteWordMute(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
user: typeof $i,
|
||||
mutedWords: Array<string | string[]> | null,
|
||||
checkForSensitiveMedia: CheckForSensitiveMedia,
|
||||
checkOnly: CheckOnly = false as CheckOnly,
|
||||
): ReturnTypeB {
|
||||
): WordMuteResult {
|
||||
if (mutedWords != null) {
|
||||
const result = checkWordMute(noteToCheck, user, mutedWords);
|
||||
if (Array.isArray(result)) return checkOnly ? (result.length > 0) as ReturnTypeB : result as ReturnTypeB;
|
||||
if (Array.isArray(result)) return result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, user, mutedWords);
|
||||
if (Array.isArray(replyResult)) return checkOnly ? (replyResult.length > 0) as ReturnTypeB : replyResult as ReturnTypeB;
|
||||
if (Array.isArray(replyResult)) return replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, user, mutedWords);
|
||||
if (Array.isArray(renoteResult)) return checkOnly ? (renoteResult.length > 0) as ReturnTypeB : renoteResult as ReturnTypeB;
|
||||
if (Array.isArray(renoteResult)) return renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false as ReturnTypeB;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkBuiltinSoftMute(
|
||||
noteToCheck: Misskey.entities.Note,
|
||||
checkForSensitiveMedia: boolean,
|
||||
): 'sensitiveMute' | false {
|
||||
if (checkForSensitiveMedia && noteToCheck.files?.some((v) => v.isSensitive)) {
|
||||
return 'sensitiveMute' as ReturnTypeB;
|
||||
return 'sensitiveMute' as never;
|
||||
}
|
||||
|
||||
return false as ReturnTypeB;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** MkNote, MkNoteDetailedの共通ロジック */
|
||||
@@ -108,7 +107,7 @@ export function useNote(
|
||||
|
||||
// プラグインの割り込み処理
|
||||
let rawNote = deepClone(props.note);
|
||||
const hideByPlugin = ref(false);
|
||||
let hideByPlugin = false;
|
||||
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
|
||||
|
||||
if (noteViewInterruptors.length > 0) {
|
||||
@@ -116,14 +115,19 @@ export function useNote(
|
||||
for (const interruptor of noteViewInterruptors) {
|
||||
try {
|
||||
result = interruptor.handler(result!) as Misskey.entities.Note | null;
|
||||
|
||||
// nullになった場合(非表示)はこれ以上やることがないのでループを抜ける
|
||||
if (result == null) {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
hideByPlugin.value = true;
|
||||
hideByPlugin = true;
|
||||
} else {
|
||||
rawNote = result as Misskey.entities.Note;
|
||||
rawNote = result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,23 +149,25 @@ export function useNote(
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
|
||||
// ミュート判定
|
||||
const muted = ref($i ? calculateMuteStatus(appearNote, $i, $i.mutedWords, inTimeline && !tl_withSensitive.value) : false);
|
||||
const hardMuted = ref(props.withHardMute && $i ? calculateMuteStatus(appearNote, $i, $i.hardMutedWords, inTimeline && !tl_withSensitive.value, true) : false);
|
||||
// mutedはミュート解除の操作で書き換わるのでrefだが、hardMutedは解除できないのでリアクティブにしない
|
||||
const muted = ref($i ? checkNoteWordMute(appearNote, $i, $i.mutedWords) || checkBuiltinSoftMute(appearNote, inTimeline && !tl_withSensitive.value) : false);
|
||||
const hardMuted = props.withHardMute && $i ? checkNoteWordMute(appearNote, $i, $i.hardMutedWords) : false;
|
||||
|
||||
// 計算プロパティ (Computed)
|
||||
const isMyRenote = computed(() => $i && ($i.id === rawNote.userId));
|
||||
const parsed = computed(() => appearNote.text ? mfm.parse(appearNote.text) : null);
|
||||
const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null);
|
||||
const isLong = computed(() => shouldCollapsed(appearNote, urls.value ?? []));
|
||||
const collapsed = ref(appearNote.cw == null && isLong.value);
|
||||
const showTicker = computed(() => (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance));
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
|
||||
// 導出値
|
||||
// rawNote / appearNote / $i.id / prefer.s は変化しないので一度だけ計算する
|
||||
const isMyRenote = $i != null && ($i.id === rawNote.userId);
|
||||
const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
|
||||
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
|
||||
const isLong = shouldCollapsed(appearNote, urls ?? []);
|
||||
const collapsed = ref(appearNote.cw == null && isLong);
|
||||
const canRenote = ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id);
|
||||
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const renoteCollapsed = ref(prefer.s.collapseRenotes && isRenote && (($i && ($i.id === rawNote.userId || $i.id === appearNote.userId)) || ($appearNote.myReaction != null)));
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
const pleaseLoginContext: OpenOnRemoteOptions = {
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.id}`,
|
||||
}));
|
||||
};
|
||||
|
||||
// グローバルイベントの監視
|
||||
useGlobalEvent('noteDeleted', (noteId) => {
|
||||
@@ -216,7 +222,7 @@ export function useNote(
|
||||
// 共通アクション関数群
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
if (els.renoteButton == null) return;
|
||||
@@ -231,7 +237,7 @@ export function useNote(
|
||||
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
|
||||
if (!isLoggedIn) return;
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
@@ -241,8 +247,8 @@ export function useNote(
|
||||
});
|
||||
}
|
||||
|
||||
async function react(customCallback?: (reaction: string) => void) {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function react(createReactionMock?: (reaction: string) => void) {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
|
||||
@@ -276,7 +282,7 @@ export function useNote(
|
||||
}
|
||||
sound.playMisskeySfx('reaction');
|
||||
if (props.mock) {
|
||||
if (customCallback) customCallback(reaction);
|
||||
if (createReactionMock) createReactionMock(reaction);
|
||||
return;
|
||||
}
|
||||
misskeyApi('notes/reactions/create', {
|
||||
@@ -294,7 +300,7 @@ export function useNote(
|
||||
|
||||
async function reactViaMfmEmoji(reaction: string) {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
|
||||
if (!isLoggedIn) return;
|
||||
showMovedDialog();
|
||||
sound.playMisskeySfx('reaction');
|
||||
@@ -372,7 +378,7 @@ export function useNote(
|
||||
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) return;
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const getUnrenote = () => ({
|
||||
@@ -403,7 +409,7 @@ export function useNote(
|
||||
menuItems.push(getCopyNoteLinkMenu(rawNote, i18n.ts.copyLinkRenote));
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
if (isMyRenote.value) {
|
||||
if (isMyRenote) {
|
||||
menuItems.push(getUnrenote());
|
||||
os.popupMenu(menuItems, els.renoteTime?.value);
|
||||
} else {
|
||||
@@ -437,7 +443,7 @@ export function useNote(
|
||||
collapsed,
|
||||
renoteCollapsed,
|
||||
|
||||
// 計算プロパティ
|
||||
// 導出値
|
||||
isMyRenote,
|
||||
parsed,
|
||||
urls,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||
import type { Content } from '@/components/MkLightbox.item.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { isPreviewable, getType } from '@/utility/lightbox.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
export type UploaderFeatures = {
|
||||
@@ -236,16 +237,16 @@ export function useUploader(options: {
|
||||
},
|
||||
});
|
||||
|
||||
if (item.file.type.startsWith('image/') || item.file.type.startsWith('video/')) {
|
||||
if (isPreviewable(item.file.type)) {
|
||||
menu.push({
|
||||
text: i18n.ts.preview,
|
||||
icon: 'ti ti-photo-search',
|
||||
action: async () => {
|
||||
const contents = items.value
|
||||
.filter(item => item.file.type.startsWith('image/') || item.file.type.startsWith('video/'))
|
||||
.filter(item => isPreviewable(item.file.type))
|
||||
.map<Content>(item => ({
|
||||
id: item.id,
|
||||
type: item.file.type.startsWith('video/') ? 'video' : 'image',
|
||||
type: getType(item.file.type),
|
||||
url: item.objectUrl,
|
||||
thumbnail: item.thumbnail,
|
||||
filename: getUploadName(item),
|
||||
|
||||
@@ -19,5 +19,5 @@ export const DI = {
|
||||
inModal: Symbol() as InjectionKey<boolean>,
|
||||
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
|
||||
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
|
||||
mkLightboxItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
|
||||
mkLightboxItemMediaEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | HTMLAudioElement | null>>,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { popup } from '@/os.js';
|
||||
|
||||
const handlers = new WeakMap<HTMLElement, (ev: MouseEvent) => void>();
|
||||
const abortControllers = new WeakMap<HTMLElement, AbortController>();
|
||||
|
||||
export const rippleDirective = {
|
||||
|
||||
@@ -125,10 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
key: 'wait',
|
||||
title: 'Waiting',
|
||||
icon: 'ti ti-hourglass-high',
|
||||
}, {
|
||||
key: 'paused',
|
||||
title: 'Paused',
|
||||
icon: 'ti ti-player-pause',
|
||||
}]"
|
||||
/>
|
||||
</template>
|
||||
@@ -192,7 +188,7 @@ import MkInput from '@/components/MkInput.vue';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
|
||||
const tab = ref<typeof Misskey.queueTypes[number] | '-'>('-');
|
||||
const jobState = ref<'all' | 'latest' | 'completed' | 'failed' | 'active' | 'delayed' | 'wait' | 'paused'>('all');
|
||||
const jobState = ref<Misskey.entities.AdminQueueJobsRequest['state'][number] | 'all' | 'latest'>('all');
|
||||
const jobs = ref<Misskey.entities.QueueJob[]>([]);
|
||||
const jobsFetching = ref(true);
|
||||
const queueInfos = ref<Misskey.entities.AdminQueueQueuesResponse>([]);
|
||||
|
||||
@@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:enableEmojiMenu="true"
|
||||
:enableEmojiMenuReaction="true"
|
||||
/>
|
||||
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
|
||||
<MkMediaList v-if="message.file" :mediaList="[message.file]" :user="message.fromUser"/>
|
||||
</MkFukidashi>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
|
||||
<div :class="$style.footer">
|
||||
|
||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else-if="file" class="_gaps">
|
||||
<div :class="$style.filePreviewRoot">
|
||||
<MkMediaList :mediaList="[file]"></MkMediaList>
|
||||
<MkMediaList :mediaList="[file]" :user="file.user"></MkMediaList>
|
||||
</div>
|
||||
<div :class="$style.fileQuickActionsRoot">
|
||||
<button class="_button" :class="$style.fileNameEditBtn" @click="rename()">
|
||||
|
||||
@@ -4,35 +4,66 @@
|
||||
*/
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false {
|
||||
export type WordMuteResult = Array<string | string[]> | false;
|
||||
|
||||
// ミュートワードの判定はタイムラインのノートごとに繰り返し実行されるため、
|
||||
// フィルタ単位の前処理(正規表現のコンパイル・空キーワードの除去)をキャッシュする
|
||||
// keywordsCacheはWeakMapによりフィルタ配列がGCされるとキャッシュも破棄される
|
||||
const regexCache = new Map<string, RegExp | null>();
|
||||
const keywordsCache = new WeakMap<string[], string[]>();
|
||||
|
||||
function getCompiledRegex(filter: string): RegExp | null {
|
||||
let regex = regexCache.get(filter);
|
||||
if (regex === undefined) {
|
||||
const parts = filter.match(/^\/(.+)\/(.*)$/);
|
||||
if (parts == null) {
|
||||
// This should never happen due to input sanitisation.
|
||||
regex = null;
|
||||
} else {
|
||||
try {
|
||||
regex = new RegExp(parts[1], parts[2]);
|
||||
} catch (_) {
|
||||
// This should never happen due to input sanitisation.
|
||||
regex = null;
|
||||
}
|
||||
}
|
||||
regexCache.set(filter, regex);
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
function getCleanedKeywords(filter: string[]): string[] {
|
||||
let keywords = keywordsCache.get(filter);
|
||||
if (keywords === undefined) {
|
||||
keywords = filter.filter(keyword => keyword !== '');
|
||||
keywordsCache.set(filter, keywords);
|
||||
}
|
||||
return keywords;
|
||||
}
|
||||
|
||||
export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): WordMuteResult {
|
||||
// 自分自身
|
||||
if (me && (note.userId === me.id)) return false;
|
||||
|
||||
if (mutedWords.length > 0) {
|
||||
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
|
||||
if (mutedWords.length === 0) return false;
|
||||
|
||||
if (text === '') return false;
|
||||
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
|
||||
|
||||
if (text !== '') {
|
||||
const matched = mutedWords.filter(filter => {
|
||||
if (Array.isArray(filter)) {
|
||||
// Clean up
|
||||
const filteredFilter = filter.filter(keyword => keyword !== '');
|
||||
if (filteredFilter.length === 0) return false;
|
||||
const keywords = getCleanedKeywords(filter);
|
||||
if (keywords.length === 0) return false;
|
||||
|
||||
return filteredFilter.every(keyword => text.includes(keyword));
|
||||
return keywords.every(keyword => text.includes(keyword));
|
||||
} else {
|
||||
// represents RegExp
|
||||
const regexp = filter.match(/^\/(.+)\/(.*)$/);
|
||||
const regex = getCompiledRegex(filter);
|
||||
if (regex == null) return false;
|
||||
|
||||
// This should never happen due to input sanitisation.
|
||||
if (!regexp) return false;
|
||||
|
||||
try {
|
||||
return new RegExp(regexp[1], regexp[2]).test(text);
|
||||
} catch (_) {
|
||||
// This should never happen due to input sanitisation.
|
||||
return false;
|
||||
}
|
||||
// gフラグ等が付いているとtestでlastIndexが進み、共有インスタンスでは結果が変わってしまうためリセットする
|
||||
regex.lastIndex = 0;
|
||||
return regex.test(text);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -303,12 +303,18 @@ export function getNoteMenu(props: {
|
||||
|
||||
async function translate(): Promise<void> {
|
||||
if (props.translation.value != null) return;
|
||||
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
|
||||
|
||||
let text = appearNote.text ?? '';
|
||||
if (appearNote.cw != null) {
|
||||
text = `${appearNote.cw}\n-----\n${text}`;
|
||||
}
|
||||
|
||||
if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && text.trim() !== '') {
|
||||
props.translating.value = true;
|
||||
try {
|
||||
// @ts-expect-error 実験的なAPIなので型定義がない
|
||||
const detector = await LanguageDetector.create();
|
||||
const langResult = await detector.detect(appearNote.text);
|
||||
const langResult = await detector.detect(text);
|
||||
let localStorageLang = miLocalStorage.getItem('lang');
|
||||
if (localStorageLang != null) {
|
||||
localStorageLang = localStorageLang.split('-')[0];
|
||||
@@ -318,7 +324,7 @@ export function getNoteMenu(props: {
|
||||
if (langResult[0]?.detectedLanguage === localStorageLang || langResult[0]?.detectedLanguage === navigator.language) {
|
||||
props.translation.value = {
|
||||
sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
|
||||
text: appearNote.text,
|
||||
text: text,
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -328,7 +334,7 @@ export function getNoteMenu(props: {
|
||||
sourceLanguage: langResult[0]?.detectedLanguage,
|
||||
targetLanguage: localStorageLang ?? navigator.language,
|
||||
});
|
||||
const translated = await translator.translate(appearNote.text);
|
||||
const translated = await translator.translate(text);
|
||||
props.translation.value = {
|
||||
sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
|
||||
text: translated,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
|
||||
export function isPreviewable(mime: string): boolean {
|
||||
if (mime === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
const strippedMime = mime.split(';')[0].trim();
|
||||
return (strippedMime.startsWith('image') || strippedMime.startsWith('video') || strippedMime.startsWith('audio')) && FILE_TYPE_BROWSERSAFE.includes(strippedMime);
|
||||
}
|
||||
|
||||
export function getType(mime: string): 'image' | 'video' | 'audio' {
|
||||
if (mime.startsWith('image')) return 'image';
|
||||
if (mime.startsWith('video')) return 'video';
|
||||
if (mime.startsWith('audio')) return 'audio';
|
||||
throw new Error(`Unsupported file type: ${mime}`);
|
||||
}
|
||||
@@ -85,6 +85,9 @@ test.describe('After setup instance', () => {
|
||||
await visitHome(page);
|
||||
|
||||
await page.getByTestId('signup').click();
|
||||
await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' });
|
||||
test.expect(await page.getByTestId('signup-rules-continue').isDisabled()).toBeTruthy();
|
||||
|
||||
await locateMkSwitch(page, 'signup-rules-notes-agree').click();
|
||||
await page.getByTestId('modal-dialog-ok').click();
|
||||
test.expect(await page.getByTestId('signup-rules-continue').isDisabled()).toBeFalsy();
|
||||
@@ -193,7 +196,6 @@ test.describe('After user setup', () => {
|
||||
await registerUser('alice', 'alice1234');
|
||||
await signIn(page, 'alice', 'alice1234');
|
||||
|
||||
// 表示に時間がかかるのでデフォルト秒数だとタイムアウトする
|
||||
await closeUserSetupDialog(page);
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export async function signupThroughUi(
|
||||
await signupResponse;
|
||||
}
|
||||
|
||||
// 表示に時間がかかるのでPlaywrightのデフォルトのタイムアウトだと間に合わない
|
||||
export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise<void> {
|
||||
await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout });
|
||||
await page.getByTestId('modal-dialog-ok').click();
|
||||
|
||||
@@ -28,16 +28,16 @@
|
||||
"built"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"chokidar": "5.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"esbuild": "0.28.2",
|
||||
"execa": "10.0.1",
|
||||
"nodemon": "3.1.14",
|
||||
"tsx": "4.23.0"
|
||||
"tsx": "4.23.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "5.2.2"
|
||||
"js-yaml": "5.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4172,6 +4172,10 @@ export interface Locale extends ILocale {
|
||||
* 読み込めません
|
||||
*/
|
||||
"cannotLoad": string;
|
||||
/**
|
||||
* プレビューできません
|
||||
*/
|
||||
"cannotPreview": string;
|
||||
/**
|
||||
* プロフィール表示回数
|
||||
*/
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/wawoff2": "1.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"harfbuzzjs": "1.4.0",
|
||||
"tsx": "4.23.0",
|
||||
"harfbuzzjs": "1.6.0",
|
||||
"tsx": "4.23.12",
|
||||
"wawoff2": "2.0.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"esbuild": "0.28.2",
|
||||
"execa": "10.0.1",
|
||||
"nodemon": "3.1.14"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
"generate": "tsx src/generator.ts && eslint ./built/**/*.ts --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@readme/openapi-parser": "6.2.1",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@readme/openapi-parser": "7.0.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"openapi-types": "12.1.3",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"ts-case-convert": "2.3.1",
|
||||
"tsx": "4.23.0",
|
||||
"tsx": "4.23.12",
|
||||
"eslint": "9.39.5"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2026.7.0",
|
||||
"version": "2026.8.0-alpha.0",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
@@ -37,13 +37,13 @@
|
||||
"directory": "packages/misskey-js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.58.9",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@microsoft/api-extractor": "7.58.12",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"esbuild": "0.28.2",
|
||||
"execa": "10.0.1",
|
||||
"ncp": "2.0.0",
|
||||
"nodemon": "3.1.14",
|
||||
"tsd": "0.33.0",
|
||||
|
||||
@@ -749,7 +749,7 @@ declare module '../api.js' {
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:queue*
|
||||
*/
|
||||
request<E extends 'admin/queue/stats', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
|
||||
@@ -616,7 +616,7 @@ export type paths = {
|
||||
* admin/queue/stats
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:queue*
|
||||
*/
|
||||
post: operations['admin___queue___stats'];
|
||||
};
|
||||
@@ -9882,7 +9882,7 @@ export interface operations {
|
||||
'application/json': {
|
||||
/** @enum {string} */
|
||||
queue: 'system' | 'endedPollNotification' | 'postScheduledNote' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
|
||||
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed' | 'paused')[];
|
||||
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed')[];
|
||||
search?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"esbuild": "0.28.2",
|
||||
"execa": "10.0.1",
|
||||
"nodemon": "3.1.14"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild": "0.28.2",
|
||||
"idb-keyval": "6.3.0",
|
||||
"misskey-js": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"nodemon": "3.1.14"
|
||||
|
||||
Generated
+2474
-2248
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user