mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-30 11:55:49 +00:00
Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39d6aecc57 | |||
| edbe30a3df | |||
| ad4751918b | |||
| bd9041bd8f | |||
| 4dcff123df | |||
| 990be44d98 | |||
| 78d65ef3dd | |||
| 0a67d6f1a0 | |||
| e312283ea0 | |||
| e8c78e12d5 | |||
| 37fe7a9634 | |||
| 729abbef62 | |||
| e74ab35de3 | |||
| 4ba18690d7 | |||
| 26c8914a26 | |||
| 119423e3ae | |||
| 3de1ce63cd | |||
| 18fbc9bb05 | |||
| 37a21cf54e | |||
| 290fd8c7cc | |||
| 0edb0133fc | |||
| 0abe021640 | |||
| 344f50d538 | |||
| 1cebad0ddb | |||
| 3e34e87a59 | |||
| b9713259a7 | |||
| e15b8b7fa3 | |||
| ca89c86426 | |||
| 6bce19655b | |||
| 78ed024b0b | |||
| 6f76b598a1 | |||
| a888f2863b | |||
| a892bbcce5 | |||
| 6571c87e14 | |||
| 9e0f18a705 | |||
| 3dfca2d61f | |||
| f1578c282e | |||
| 231a6877be | |||
| d0a5ccc1ec | |||
| 4e16e23acd | |||
| 17da44078b | |||
| 1f29fb4e40 | |||
| 0343b4e689 | |||
| a76a1a6305 | |||
| 1f934ada5d | |||
| cd77404006 | |||
| 351386c8ff | |||
| 298f8802d4 | |||
| 6e614ff061 | |||
| ca76ba1871 | |||
| 9f768b8022 | |||
| 4ff826eb3d | |||
| 60bcb9c6a9 | |||
| 0c85bfd56f | |||
| 9d37f696b4 | |||
| 2734ff6af7 | |||
| 456504cf82 | |||
| 81cea6aed5 | |||
| 2d1b7c957a | |||
| 5b6aa1496a | |||
| 259dd34b26 | |||
| cf81406fae | |||
| 42f230f223 | |||
| 2e07e50bb4 | |||
| d203e1a446 | |||
| 4988719a2e | |||
| f0380f2d1c | |||
| 130d065d0c | |||
| 7b41fddf54 | |||
| aafd8b6bf7 | |||
| 7a82c1a912 | |||
| 3c97c12e7f | |||
| 5b5a1f08e1 | |||
| c4ee95a40a | |||
| 8ea7fe0ba1 | |||
| 187b6477da | |||
| 09896fdc12 | |||
| d4cda989a2 | |||
| f93043e170 | |||
| 1c2e57d60c | |||
| 67c853104c | |||
| 986d783940 | |||
| 61cfccff37 | |||
| b161fe7adc | |||
| c3b1c8a8ff | |||
| bc2b512be6 | |||
| 7fe9574897 | |||
| 8549f71656 | |||
| ae4487fa69 | |||
| 0a75d6fcf1 | |||
| d3bb24d851 | |||
| 9aea7363ce | |||
| f0889f4b3c | |||
| b8433b2413 | |||
| 4e9070a4c5 | |||
| bff194f648 | |||
| 8714945ec9 | |||
| aadc7bf61a | |||
| acd35ef96c | |||
| 43919a3fe3 | |||
| 8a21202281 | |||
| 579499a7df | |||
| b48233eb4c |
@@ -5,7 +5,7 @@
|
||||
"workspaceFolder": "/workspace",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22.15.0"
|
||||
"version": "24.10.0"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/pnpm:2": {
|
||||
"version": "10.10.0"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/checkout@v4.3.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
ref: ${{ matrix.ref }}
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Request release review
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
reply:
|
||||
if: github.event.comment.body == '/request-release-review'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Reply
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const body = `To dev team (@misskey-dev/dev):
|
||||
|
||||
リリースが提案されています :rocket:
|
||||
|
||||
GOの場合はapprove、NO GOの場合はその旨コメントをお願いいたします。
|
||||
|
||||
判断にあたって考慮すべき観点は、
|
||||
|
||||
- やり残したことはないか?
|
||||
- CHANGELOGは過不足ないか?
|
||||
- バージョンに問題はないか?(月跨いでいるのに更新忘れているなど)
|
||||
- 再考すべき仕様・実装はないか?
|
||||
- ベータ版を検証したサーバーから不具合の報告等は上がってないか?
|
||||
- (セキュリティの修正や重要なバグ修正などのため)リリースを急いだ方が良いか?そうではないか?
|
||||
- Actionsが落ちていないか?
|
||||
|
||||
などが挙げられます。
|
||||
|
||||
ご協力ありがとうございます :sparkles:
|
||||
`
|
||||
|
||||
const issue_number = context.payload.issue ? context.payload.issue.number : (context.payload.pull_request && context.payload.pull_request.number)
|
||||
if (!issue_number) {
|
||||
console.log('No issue or PR number found in payload; skipping')
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)"
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Get current date
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
#- uses: browser-actions/setup-firefox@latest
|
||||
# if: ${{ matrix.browser == 'firefox' }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
uses: actions/checkout@v4.3.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.1.0
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4.4.0
|
||||
with:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
22.15.0
|
||||
24.10.0
|
||||
|
||||
+42
-2
@@ -1,20 +1,60 @@
|
||||
## 2025.11.0
|
||||
|
||||
### General
|
||||
- Feat: チャンネルミュート機能の実装 #10649
|
||||
- チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列)
|
||||
- Enhance: Node.js 24.10.0をサポートするようになりました
|
||||
- Enhance: DockerのNode.jsが24.10.0に更新されました
|
||||
|
||||
### Client
|
||||
- Feat: 画像にメタデータを含むフレームをつけられる機能
|
||||
- Enhance: プリセットを作成しなくても画像にウォーターマークを付与できるように
|
||||
- Enhance: 管理しているチャンネルの見分けがつきやすくなるように
|
||||
- Enhance: プロフィールへのリンクをユーザーポップアップのアバターに追加
|
||||
- Enhance: ユーザーのノート、フォロー、フォロワーページへのリンクをユーザーポップアップに追加
|
||||
- Enhance: プッシュ通知を行うための権限確認をより確実に行うように
|
||||
- Enhance: 投稿フォームのチュートリアルを追加
|
||||
- Enhance: 「自動でもっと見る」をほとんどの箇所で利用可能に
|
||||
- Fix: 紙吹雪エフェクトがアニメーション設定を考慮せず常に表示される問題を修正
|
||||
- Fix: ナビゲーションバーのリアルタイムモード切替ボタンの状態をよりわかりやすく表示するように
|
||||
- Fix: ページのタイトルが長いとき、はみ出る問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: Remote Notes Cleaningが複雑度が高いノートの処理を中断せずに次のノートから再開するように
|
||||
|
||||
## 2025.10.2
|
||||
|
||||
### Client
|
||||
- Fix: アプリ内からキャッシュをクリアするとテーマ再適用するまでレンダリングが正しく行われない問題を修正
|
||||
- Fix: 期限が無期限のアンケートに投票できない問題を修正
|
||||
|
||||
## 2025.10.1
|
||||
|
||||
### General
|
||||
- Enhance: リモートユーザーのロールバッジを表示できるように(オプトイン)
|
||||
- Enhance: リモートユーザーに付与したロールバッジを表示できるように(オプトイン)
|
||||
パフォーマンス上の問題からデフォルトで無効化されています。「コントロールパネル > パフォーマンス」から有効化できます。
|
||||
- 依存関係の更新
|
||||
|
||||
### Client
|
||||
- Enhance: デッキのメインカラムのヘッダをクリックしてページ上部/下部にスクロールできるように
|
||||
- Enhance: 下書き/予約投稿一覧は投稿フォームのアカウントメニュー内に移動し、下書き保存は「...」メニュー内に移動されました
|
||||
- Fix: カスタム絵文字画面(beta)のaliasesで使用される区切り文字が一致していないのを修正 #15614
|
||||
- Fix: バナー画像の幅が表示領域と一致していない問題を修正
|
||||
- Fix: 一部のブラウザでバナー画像が上下中央に表示されない問題を修正
|
||||
- Fix: ナビゲーションバーの設定で削除した項目をその場で再追加できない問題を修正
|
||||
- Fix: ロールポリシーによりダイレクトメッセージが無効化されている際のデッキのダイレクトメッセージカラムの挙動を改善
|
||||
- Fix: 画像のマスクでタッチ操作が不安定な問題を修正
|
||||
- Fix: ウォーターマークの各種挙動修正
|
||||
- ウォーターマークを回転させると歪む問題を修正
|
||||
- ウォーターマークを敷き詰めると上下左右反転した画像/文字が表示される問題を修正
|
||||
- ウォーターマークを回転させた際に画面からはみ出た部分を考慮できるように
|
||||
- Fix: 投票が終了した後に投票結果が正しく表示されない問題を修正
|
||||
- Fix: ダークモードの同期が機能しない場合がある問題を修正
|
||||
- Fix: iOSで動画の圧縮を行うと音声トラックが失われる問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
- Enhance: 管理者/モデレーターはファイルのアップロード制限をバイパスするように
|
||||
- Enhance: セキュリティの向上
|
||||
|
||||
## 2025.10.0
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.4
|
||||
|
||||
ARG NODE_VERSION=22.15.0-bookworm
|
||||
ARG NODE_VERSION=24.10.0-bookworm
|
||||
|
||||
# build assets & compile TypeScript
|
||||
|
||||
|
||||
@@ -775,6 +775,7 @@ lockedAccountInfo: "Tret que establiu la visibilitat de la nota a \"Només segui
|
||||
alwaysMarkSensitive: "Marcar com a sensible per defecte"
|
||||
loadRawImages: "Carregar les imatges originals en comptes de miniatures "
|
||||
disableShowingAnimatedImages: "No reproduir imatges animades"
|
||||
disableShowingAnimatedImages_caption: "Si les imatges animades no es reprodueixen, independentment d'aquesta configuració, és possible que la configuració d'accessibilitat del navegador i el sistema operatiu, els modes d'estalvi d'energia i similars estiguin interferint."
|
||||
highlightSensitiveMedia: "Ressalta els medis marcats com a sensibles"
|
||||
verificationEmailSent: "S'ha enviat un correu electrònic de verificació. Fes clic a l'enllaç per completar la verificació."
|
||||
notSet: "Sense definir"
|
||||
@@ -1171,6 +1172,7 @@ installed: "Instal·lats "
|
||||
branding: "Marca"
|
||||
enableServerMachineStats: "Publicar estadístiques del maquinari del servidor"
|
||||
enableIdenticonGeneration: "Activar la generació d'icones d'identificació "
|
||||
showRoleBadgesOfRemoteUsers: "Mostrar insígnies de rols d'instàncies remotes "
|
||||
turnOffToImprovePerformance: "Desactivant aquesta opció es pot millorar el rendiment."
|
||||
createInviteCode: "Crear codi d'invitació "
|
||||
createWithOptions: "Crear invitació amb opcions"
|
||||
@@ -3199,6 +3201,7 @@ _watermarkEditor:
|
||||
title: "Editar la marca d'aigua "
|
||||
cover: "Cobrir-ho tot"
|
||||
repeat: "Repetir"
|
||||
preserveBoundingRect: "Ajusta'l per evitar que sobresortir en fer la rotació "
|
||||
opacity: "Opacitat"
|
||||
scale: "Mida"
|
||||
text: "Text"
|
||||
|
||||
@@ -775,6 +775,7 @@ lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", yo
|
||||
alwaysMarkSensitive: "Mark as sensitive by default"
|
||||
loadRawImages: "Load original images instead of showing thumbnails"
|
||||
disableShowingAnimatedImages: "Don't play animated images"
|
||||
disableShowingAnimatedImages_caption: "If animated images do not play even if this setting is disabled, it may be due to browser or OS accessibility settings, power-saving settings, or similar factors."
|
||||
highlightSensitiveMedia: "Highlight sensitive media"
|
||||
verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification."
|
||||
notSet: "Not set"
|
||||
@@ -1171,6 +1172,7 @@ installed: "Installed"
|
||||
branding: "Branding"
|
||||
enableServerMachineStats: "Publish server hardware stats"
|
||||
enableIdenticonGeneration: "Enable user identicon generation"
|
||||
showRoleBadgesOfRemoteUsers: "Display the role badges assigned to remote users"
|
||||
turnOffToImprovePerformance: "Turning this off can increase performance."
|
||||
createInviteCode: "Generate invite"
|
||||
createWithOptions: "Generate with options"
|
||||
@@ -3199,6 +3201,7 @@ _watermarkEditor:
|
||||
title: "Edit Watermark"
|
||||
cover: "Cover everything"
|
||||
repeat: "spread all over"
|
||||
preserveBoundingRect: "Adjust to prevent overflow when rotating"
|
||||
opacity: "Opacity"
|
||||
scale: "Size"
|
||||
text: "Text"
|
||||
|
||||
@@ -775,6 +775,7 @@ lockedAccountInfo: "A menos que configures la visibilidad de tus notas como \"S
|
||||
alwaysMarkSensitive: "Marcar los medios de comunicación como contenido sensible por defecto"
|
||||
loadRawImages: "Cargar las imágenes originales en lugar de mostrar las miniaturas"
|
||||
disableShowingAnimatedImages: "No reproducir imágenes animadas"
|
||||
disableShowingAnimatedImages_caption: "Si las imágenes animadas no se reproducen independientemente de esta configuración, es posible que la configuración de accesibilidad del navegador o del sistema operativo, los modos de ahorro de energía o funciones similares estén interfiriendo."
|
||||
highlightSensitiveMedia: "Resaltar medios marcados como sensibles"
|
||||
verificationEmailSent: "Se le ha enviado un correo electrónico de confirmación. Por favor, acceda al enlace proporcionado en el correo electrónico para completar la configuración."
|
||||
notSet: "Sin especificar"
|
||||
@@ -1171,6 +1172,7 @@ installed: "Instalado"
|
||||
branding: "Marca"
|
||||
enableServerMachineStats: "Publicar estadísticas de hardware del servidor"
|
||||
enableIdenticonGeneration: "Activar generación de identicon por usuario"
|
||||
showRoleBadgesOfRemoteUsers: "Mostrar la insignia de rol asignada a los usuarios remotos."
|
||||
turnOffToImprovePerformance: "Desactivar esto puede aumentar el rendimiento."
|
||||
createInviteCode: "Generar invitación"
|
||||
createWithOptions: "Generar con opciones"
|
||||
@@ -3199,6 +3201,7 @@ _watermarkEditor:
|
||||
title: "Editar la marca de agua"
|
||||
cover: "Cubrir todo"
|
||||
repeat: "Repetir"
|
||||
preserveBoundingRect: "Ajuste para evitar que se desborde al rotar."
|
||||
opacity: "Opacidad"
|
||||
scale: "Tamaño"
|
||||
text: "Texto"
|
||||
|
||||
@@ -298,6 +298,7 @@ uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesa
|
||||
explore: "Jelajahi"
|
||||
messageRead: "Telah dibaca"
|
||||
noMoreHistory: "Tidak ada sejarah lagi"
|
||||
startChat: "Kirim pesan"
|
||||
nUsersRead: "Dibaca oleh {n}"
|
||||
agreeTo: "Saya setuju kepada {0}"
|
||||
agree: "Setuju"
|
||||
@@ -510,6 +511,7 @@ emojiStyle: "Gaya emoji"
|
||||
native: "Native"
|
||||
menuStyle: "Gaya menu"
|
||||
style: "Gaya"
|
||||
popup: "Pemunculan"
|
||||
showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk"
|
||||
showReactionsCount: "Lihat jumlah reaksi dalam catatan"
|
||||
noHistory: "Tidak ada riwayat"
|
||||
@@ -566,6 +568,7 @@ showFixedPostForm: "Tampilkan form posting di atas lini masa"
|
||||
showFixedPostFormInChannel: "Tampilkan form posting di atas lini masa (Kanal)"
|
||||
withRepliesByDefaultForNewlyFollowed: "Termasuk balasan dari pengguna baru yang diikuti pada lini masa secara bawaan"
|
||||
newNoteRecived: "Kamu mendapat catatan baru"
|
||||
newNote: "Catatan baru"
|
||||
sounds: "Bunyi"
|
||||
sound: "Bunyi"
|
||||
listen: "Dengarkan"
|
||||
@@ -1028,6 +1031,7 @@ permissionDeniedError: "Operasi ditolak"
|
||||
permissionDeniedErrorDescription: "Akun ini tidak memiliki izin untuk melakukan aksi ini."
|
||||
preset: "Prasetel"
|
||||
selectFromPresets: "Pilih dari prasetel"
|
||||
custom: "Penyesuaian"
|
||||
achievements: "Pencapaian"
|
||||
gotInvalidResponseError: "Respon peladen tidak valid"
|
||||
gotInvalidResponseErrorDescription: "Peladen tidak dapat dijangkau atau sedang dalam perawatan. Mohon coba lagi nanti."
|
||||
@@ -1110,6 +1114,7 @@ preservedUsernamesDescription: "Daftar nama pengguna yang dicadangkan dipisah de
|
||||
createNoteFromTheFile: "Buat catatan dari berkas ini"
|
||||
archive: "Arsipkan"
|
||||
archived: "Diarsipkan"
|
||||
unarchive: "Batalkan pengarsipan"
|
||||
channelArchiveConfirmTitle: "Yakin untuk mengarsipkan {name}?"
|
||||
channelArchiveConfirmDescription: "Kanal yang diarsipkan tidak akan muncul pada daftar kanal atau hasil pencarian. Postingan baru juga tidak dapat ditambahkan lagi."
|
||||
thisChannelArchived: "Kanal ini telah diarsipkan."
|
||||
@@ -1251,6 +1256,7 @@ noDescription: "Tidak ada deskripsi"
|
||||
alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti"
|
||||
inquiry: "Hubungi kami"
|
||||
tryAgain: "Silahkan coba lagi."
|
||||
sensitiveMediaRevealConfirm: "Media sensitif. Apakah ingin melihat?"
|
||||
createdLists: "Senarai yang dibuat"
|
||||
createdAntennas: "Antena yang dibuat"
|
||||
fromX: "Dari {x}"
|
||||
@@ -1258,21 +1264,43 @@ noteOfThisUser: "Catatan oleh pengguna ini"
|
||||
clipNoteLimitExceeded: "Klip ini tak bisa ditambahi lagi catatan."
|
||||
performance: "Kinerja"
|
||||
modified: "Diubah"
|
||||
discard: "Buang"
|
||||
thereAreNChanges: "Ada {n} perubahan"
|
||||
signinWithPasskey: "Masuk dengan kunci sandi"
|
||||
unknownWebAuthnKey: "Kunci sandi tidak terdaftar."
|
||||
passkeyVerificationFailed: "Verifikasi kunci sandi gagal."
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Verifikasi kunci sandi berhasil, namun pemasukan tanpa sandi dinonaktifkan."
|
||||
messageToFollower: "Pesan kepada pengikut"
|
||||
prohibitedWordsForNameOfUser: "Kata yang dilarang untuk nama pengguna"
|
||||
lockdown: "Kuncitara"
|
||||
noName: "Tidak ada nama"
|
||||
skip: "Lewati"
|
||||
paste: "Tempel"
|
||||
emojiPalette: "Palet emoji"
|
||||
postForm: "Buat catatan"
|
||||
information: "Informasi"
|
||||
chat: "Obrolan"
|
||||
directMessage: "Obrolan pengguna"
|
||||
right: "Kanan"
|
||||
bottom: "Bawah"
|
||||
top: "Atas"
|
||||
advice: "Saran"
|
||||
inMinutes: "menit"
|
||||
inDays: "hari"
|
||||
widgets: "Widget"
|
||||
_chat:
|
||||
invitations: "Undang"
|
||||
history: "Riwayat obrolan"
|
||||
noHistory: "Tidak ada riwayat"
|
||||
members: "Anggota"
|
||||
home: "Beranda"
|
||||
send: "Kirim"
|
||||
chatWithThisUser: "Obrolan pengguna"
|
||||
_settings:
|
||||
webhook: "Webhook"
|
||||
contentsUpdateFrequency: "Frekuensi pembaruan konten"
|
||||
_preferencesProfile:
|
||||
profileName: "Nama profil"
|
||||
_abuseUserReport:
|
||||
accept: "Setuju"
|
||||
reject: "Tolak"
|
||||
@@ -1966,6 +1994,7 @@ _sfx:
|
||||
noteMy: "Catatan (Saya)"
|
||||
notification: "Notifikasi"
|
||||
reaction: "Ketika memilih reaksi"
|
||||
chatMessage: "Obrolan pengguna"
|
||||
_soundSettings:
|
||||
driveFile: "Menggunakan berkas audio dalam Drive"
|
||||
driveFileWarn: "Pilih berkas audio dari Drive"
|
||||
@@ -2168,6 +2197,7 @@ _widgets:
|
||||
chooseList: "Pilih daftar"
|
||||
clicker: "Pengeklik"
|
||||
birthdayFollowings: "Pengguna yang merayakan hari ulang tahunnya hari ini"
|
||||
chat: "Obrolan pengguna"
|
||||
_cw:
|
||||
hide: "Sembunyikan"
|
||||
show: "Lihat konten"
|
||||
@@ -2416,6 +2446,7 @@ _deck:
|
||||
mentions: "Sebutan"
|
||||
direct: "Langsung"
|
||||
roleTimeline: "Lini masa peran"
|
||||
chat: "Obrolan pengguna"
|
||||
_dialog:
|
||||
charactersExceeded: "Kamu telah melebihi batas karakter maksimum! Saat ini pada {current} dari {max}."
|
||||
charactersBelow: "Kamu berada di bawah batas minimum karakter! Saat ini pada {current} dari {min}."
|
||||
|
||||
Vendored
+271
-3
@@ -747,7 +747,7 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"flagShowTimelineRepliesDescription": string;
|
||||
/**
|
||||
* フォロー中ユーザーからのフォロリクを自動承認
|
||||
* フォロー中ユーザーからのフォロー申請を自動承認
|
||||
*/
|
||||
"autoAcceptFollowed": string;
|
||||
/**
|
||||
@@ -1226,6 +1226,10 @@ export interface Locale extends ILocale {
|
||||
* 既読
|
||||
*/
|
||||
"messageRead": string;
|
||||
/**
|
||||
* すべてのメッセージを既読にする
|
||||
*/
|
||||
"readAllChatMessages": string;
|
||||
/**
|
||||
* これより過去の履歴はありません
|
||||
*/
|
||||
@@ -4106,6 +4110,18 @@ export interface Locale extends ILocale {
|
||||
* 端末の電池消費量が増加する可能性があります。
|
||||
*/
|
||||
"sendPushNotificationReadMessageCaption": string;
|
||||
/**
|
||||
* ブラウザの通知設定を許可してください
|
||||
*/
|
||||
"pleaseAllowPushNotification": string;
|
||||
/**
|
||||
* 通知の送信権限の取得に失敗しました
|
||||
*/
|
||||
"browserPushNotificationDisabled": string;
|
||||
/**
|
||||
* {serverName}から通知を送信する権限がありません。ブラウザの設定から通知を許可して再度お試しください。
|
||||
*/
|
||||
"browserPushNotificationDisabledDescription": ParameterizedString<"serverName">;
|
||||
/**
|
||||
* 最大化
|
||||
*/
|
||||
@@ -4707,7 +4723,7 @@ export interface Locale extends ILocale {
|
||||
*/
|
||||
"enableIdenticonGeneration": string;
|
||||
/**
|
||||
* リモートユーザーのロールバッジを表示する
|
||||
* リモートユーザーに付与したロールバッジを表示する
|
||||
*/
|
||||
"showRoleBadgesOfRemoteUsers": string;
|
||||
/**
|
||||
@@ -5605,6 +5621,176 @@ export interface Locale extends ILocale {
|
||||
* 技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。
|
||||
*/
|
||||
"deviceInfoDescription": string;
|
||||
/**
|
||||
* あなたは管理者です
|
||||
*/
|
||||
"youAreAdmin": string;
|
||||
/**
|
||||
* フレーム
|
||||
*/
|
||||
"frame": string;
|
||||
/**
|
||||
* プリセット
|
||||
*/
|
||||
"presets": string;
|
||||
/**
|
||||
* ゼロ埋め
|
||||
*/
|
||||
"zeroPadding": string;
|
||||
"_imageEditing": {
|
||||
"_vars": {
|
||||
/**
|
||||
* ファイルのキャプション
|
||||
*/
|
||||
"caption": string;
|
||||
/**
|
||||
* ファイル名
|
||||
*/
|
||||
"filename": string;
|
||||
/**
|
||||
* 拡張子無しファイル名
|
||||
*/
|
||||
"filename_without_ext": string;
|
||||
/**
|
||||
* 撮影年
|
||||
*/
|
||||
"year": string;
|
||||
/**
|
||||
* 撮影月
|
||||
*/
|
||||
"month": string;
|
||||
/**
|
||||
* 撮影日
|
||||
*/
|
||||
"day": string;
|
||||
/**
|
||||
* 撮影した時刻(時)
|
||||
*/
|
||||
"hour": string;
|
||||
/**
|
||||
* 撮影した時刻(分)
|
||||
*/
|
||||
"minute": string;
|
||||
/**
|
||||
* 撮影した時刻(秒)
|
||||
*/
|
||||
"second": string;
|
||||
/**
|
||||
* カメラ名
|
||||
*/
|
||||
"camera_model": string;
|
||||
/**
|
||||
* レンズ名
|
||||
*/
|
||||
"camera_lens_model": string;
|
||||
/**
|
||||
* 焦点距離
|
||||
*/
|
||||
"camera_mm": string;
|
||||
/**
|
||||
* 焦点距離(35mm判換算)
|
||||
*/
|
||||
"camera_mm_35": string;
|
||||
/**
|
||||
* 絞り
|
||||
*/
|
||||
"camera_f": string;
|
||||
/**
|
||||
* シャッタースピード
|
||||
*/
|
||||
"camera_s": string;
|
||||
/**
|
||||
* ISO感度
|
||||
*/
|
||||
"camera_iso": string;
|
||||
/**
|
||||
* 緯度
|
||||
*/
|
||||
"gps_lat": string;
|
||||
/**
|
||||
* 経度
|
||||
*/
|
||||
"gps_long": string;
|
||||
};
|
||||
};
|
||||
"_imageFrameEditor": {
|
||||
/**
|
||||
* フレームの編集
|
||||
*/
|
||||
"title": string;
|
||||
/**
|
||||
* 画像にフレームやメタデータを含んだラベルを追加して装飾できます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
* ヘッダー
|
||||
*/
|
||||
"header": string;
|
||||
/**
|
||||
* フッター
|
||||
*/
|
||||
"footer": string;
|
||||
/**
|
||||
* フチの幅
|
||||
*/
|
||||
"borderThickness": string;
|
||||
/**
|
||||
* ラベルの幅
|
||||
*/
|
||||
"labelThickness": string;
|
||||
/**
|
||||
* ラベルのスケール
|
||||
*/
|
||||
"labelScale": string;
|
||||
/**
|
||||
* 中央揃え
|
||||
*/
|
||||
"centered": string;
|
||||
/**
|
||||
* キャプション(大)
|
||||
*/
|
||||
"captionMain": string;
|
||||
/**
|
||||
* キャプション(小)
|
||||
*/
|
||||
"captionSub": string;
|
||||
/**
|
||||
* 利用可能な変数
|
||||
*/
|
||||
"availableVariables": string;
|
||||
/**
|
||||
* 二次元コード
|
||||
*/
|
||||
"withQrCode": string;
|
||||
/**
|
||||
* 背景色
|
||||
*/
|
||||
"backgroundColor": string;
|
||||
/**
|
||||
* 文字色
|
||||
*/
|
||||
"textColor": string;
|
||||
/**
|
||||
* フォント
|
||||
*/
|
||||
"font": string;
|
||||
/**
|
||||
* セリフ
|
||||
*/
|
||||
"fontSerif": string;
|
||||
/**
|
||||
* サンセリフ
|
||||
*/
|
||||
"fontSansSerif": string;
|
||||
/**
|
||||
* 保存せずに終了しますか?
|
||||
*/
|
||||
"quitWithoutSaveConfirm": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
};
|
||||
"_compression": {
|
||||
"_quality": {
|
||||
/**
|
||||
@@ -5997,6 +6183,10 @@ export interface Locale extends ILocale {
|
||||
* ページのタブバーを下部に表示
|
||||
*/
|
||||
"showPageTabBarBottom": string;
|
||||
/**
|
||||
* 絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。
|
||||
*/
|
||||
"emojiPaletteBanner": string;
|
||||
"_chat": {
|
||||
/**
|
||||
* 送信者の名前を表示
|
||||
@@ -6059,6 +6249,10 @@ export interface Locale extends ILocale {
|
||||
* 設定のバックアップが見つかりました
|
||||
*/
|
||||
"backupFound": string;
|
||||
/**
|
||||
* 設定の強制バックアップ
|
||||
*/
|
||||
"forceBackup": string;
|
||||
};
|
||||
"_accountSettings": {
|
||||
/**
|
||||
@@ -9836,6 +10030,60 @@ export interface Locale extends ILocale {
|
||||
* チャンネルに投稿...
|
||||
*/
|
||||
"channelPlaceholder": string;
|
||||
/**
|
||||
* フォームの説明を表示
|
||||
*/
|
||||
"showHowToUse": string;
|
||||
"_howToUse": {
|
||||
/**
|
||||
* 本文
|
||||
*/
|
||||
"content_title": string;
|
||||
/**
|
||||
* 投稿する内容を入力します。
|
||||
*/
|
||||
"content_description": string;
|
||||
/**
|
||||
* ツールバー
|
||||
*/
|
||||
"toolbar_title": string;
|
||||
/**
|
||||
* ファイルやアンケートの添付、注釈やハッシュタグの設定、絵文字やメンションの挿入などが行えます。
|
||||
*/
|
||||
"toolbar_description": string;
|
||||
/**
|
||||
* アカウントメニュー
|
||||
*/
|
||||
"account_title": string;
|
||||
/**
|
||||
* 投稿するアカウントを切り替えたり、アカウントに保存した下書き・予約投稿を一覧できます。
|
||||
*/
|
||||
"account_description": string;
|
||||
/**
|
||||
* 公開範囲
|
||||
*/
|
||||
"visibility_title": string;
|
||||
/**
|
||||
* ノートを公開する範囲の設定が行えます。
|
||||
*/
|
||||
"visibility_description": string;
|
||||
/**
|
||||
* メニュー
|
||||
*/
|
||||
"menu_title": string;
|
||||
/**
|
||||
* 下書きへの保存、投稿の予約、リアクションの設定など、その他のアクションが行えます。
|
||||
*/
|
||||
"menu_description": string;
|
||||
/**
|
||||
* 投稿ボタン
|
||||
*/
|
||||
"submit_title": string;
|
||||
/**
|
||||
* ノートを投稿します。Ctrl + Enter / Cmd + Enter でも投稿できます。
|
||||
*/
|
||||
"submit_description": string;
|
||||
};
|
||||
"_placeholders": {
|
||||
/**
|
||||
* いまどうしてる?
|
||||
@@ -10890,6 +11138,14 @@ export interface Locale extends ILocale {
|
||||
};
|
||||
};
|
||||
"_moderationLogTypes": {
|
||||
/**
|
||||
* ジョブキューをクリア
|
||||
*/
|
||||
"clearQueue": string;
|
||||
/**
|
||||
* キューのジョブを再試行
|
||||
*/
|
||||
"promoteQueue": string;
|
||||
/**
|
||||
* ロールを作成
|
||||
*/
|
||||
@@ -12318,7 +12574,7 @@ export interface Locale extends ILocale {
|
||||
"defaultPreset": string;
|
||||
"_watermarkEditor": {
|
||||
/**
|
||||
* 画像にクレジット情報などのウォーターマークを追加することができます。
|
||||
* 画像にクレジット情報などのウォーターマークを追加できます。
|
||||
*/
|
||||
"tip": string;
|
||||
/**
|
||||
@@ -12345,6 +12601,10 @@ export interface Locale extends ILocale {
|
||||
* 敷き詰める
|
||||
*/
|
||||
"repeat": string;
|
||||
/**
|
||||
* 回転時はみ出ないように調整する
|
||||
*/
|
||||
"preserveBoundingRect": string;
|
||||
/**
|
||||
* 不透明度
|
||||
*/
|
||||
@@ -12429,6 +12689,10 @@ export interface Locale extends ILocale {
|
||||
* 空欄にするとアカウントのURLになります
|
||||
*/
|
||||
"leaveBlankToAccountUrl": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
};
|
||||
"_imageEffector": {
|
||||
/**
|
||||
@@ -12447,6 +12711,10 @@ export interface Locale extends ILocale {
|
||||
* 設定項目はありません
|
||||
*/
|
||||
"nothingToConfigure": string;
|
||||
/**
|
||||
* 画像の読み込みに失敗しました
|
||||
*/
|
||||
"failedToLoadImage": string;
|
||||
"_fxs": {
|
||||
/**
|
||||
* 色収差
|
||||
|
||||
+11
-9
@@ -458,7 +458,7 @@ setupOf2fa: "Impostare l'autenticazione a due fattori"
|
||||
totp: "App di autenticazione a due fattori (2FA/MFA)"
|
||||
totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App di autenticazione a due fattori (2FA/MFA)"
|
||||
moderator: "Moderatore"
|
||||
moderation: "moderazione"
|
||||
moderation: "Moderazione"
|
||||
moderationNote: "Promemoria di moderazione"
|
||||
moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori."
|
||||
addModerationNote: "Aggiungi promemoria di moderazione"
|
||||
@@ -775,6 +775,7 @@ lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"So
|
||||
alwaysMarkSensitive: "Segnare automaticamente come espliciti gli allegati"
|
||||
loadRawImages: "Visualizza le intere immagini allegate invece delle miniature."
|
||||
disableShowingAnimatedImages: "Disabilitare le immagini animate"
|
||||
disableShowingAnimatedImages_caption: "L'attivazione delle animazioni immagini potrebbe interferire sull'accessibilità e sul risparmio energetico nel dispositivo."
|
||||
highlightSensitiveMedia: "Evidenzia i media espliciti"
|
||||
verificationEmailSent: "Una mail di verifica è stata inviata. Si prega di accedere al collegamento per compiere la verifica."
|
||||
notSet: "Non impostato"
|
||||
@@ -902,7 +903,7 @@ useBlurEffect: "Utilizza effetto sfocatura"
|
||||
learnMore: "Per saperne di più"
|
||||
misskeyUpdated: "Misskey è stato aggiornato!"
|
||||
whatIsNew: "Informazioni sull'aggiornamento"
|
||||
translate: "Traduci"
|
||||
translate: "Traduzione"
|
||||
translatedFrom: "Traduzione da {x}"
|
||||
accountDeletionInProgress: "È in corso l'eliminazione del profilo"
|
||||
usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito."
|
||||
@@ -913,7 +914,7 @@ pubSub: "Publish/Subscribe del profilo"
|
||||
lastCommunication: "La comunicazione più recente"
|
||||
resolved: "Risolto"
|
||||
unresolved: "Non risolto"
|
||||
breakFollow: "Rimuovi Follower"
|
||||
breakFollow: "Rimuovere Follower"
|
||||
breakFollowConfirm: "Vuoi davvero togliere questo Follower?"
|
||||
itsOn: "Abilitato"
|
||||
itsOff: "Disabilitato"
|
||||
@@ -1171,6 +1172,7 @@ installed: "Installazione avvenuta"
|
||||
branding: "Branding"
|
||||
enableServerMachineStats: "Pubblicare le informazioni sul server"
|
||||
enableIdenticonGeneration: "Generazione automatica delle Identicon"
|
||||
showRoleBadgesOfRemoteUsers: "Visualizza i badge per i ruoli concessi ai profili remoti"
|
||||
turnOffToImprovePerformance: "Disattiva, per migliorare le prestazioni"
|
||||
createInviteCode: "Genera codice di invito"
|
||||
createWithOptions: "Genera con opzioni"
|
||||
@@ -1293,7 +1295,7 @@ sensitiveMediaRevealConfirm: "Questo allegato è esplicito, vuoi vederlo?"
|
||||
createdLists: "Liste create"
|
||||
createdAntennas: "Antenne create"
|
||||
fromX: "Da {x}"
|
||||
genEmbedCode: "Ottieni il codice di incorporamento"
|
||||
genEmbedCode: "Ottieni il codice per incorporare"
|
||||
noteOfThisUser: "Elenco di Note di questo profilo"
|
||||
clipNoteLimitExceeded: "Non è possibile aggiungere ulteriori Note a questa Clip."
|
||||
performance: "Prestazioni"
|
||||
@@ -1347,7 +1349,7 @@ postForm: "Finestra di pubblicazione"
|
||||
textCount: "Il numero di caratteri"
|
||||
information: "Informazioni"
|
||||
chat: "Chat"
|
||||
directMessage: "Chatta con questa persona"
|
||||
directMessage: "Chattare insieme"
|
||||
directMessage_short: "Messaggio"
|
||||
migrateOldSettings: "Migrare le vecchie impostazioni"
|
||||
migrateOldSettings_description: "Di solito, viene fatto automaticamente. Se per qualche motivo non fossero migrate con successo, è possibile avviare il processo di migrazione manualmente, sovrascrivendo le configurazioni attuali."
|
||||
@@ -1385,7 +1387,7 @@ pluginsAreDisabledBecauseSafeMode: "Tutti i plugin sono disattivati, poiché la
|
||||
customCssIsDisabledBecauseSafeMode: "Il CSS personalizzato non è stato applicato, poiché la modalità sicura è attiva."
|
||||
themeIsDefaultBecauseSafeMode: "Quando la modalità sicura è attiva, viene utilizzato il tema predefinito. Quando la modalità sicura viene disattivata, il tema torna a essere quello precedente."
|
||||
thankYouForTestingBeta: "Grazie per la tua collaborazione nella verifica delle versioni beta!"
|
||||
createUserSpecifiedNote: "Creare Nota personalizzata"
|
||||
createUserSpecifiedNote: "Crea Nota privata"
|
||||
schedulePost: "Pianificare la pubblicazione"
|
||||
scheduleToPostOnX: "Pianificare la pubblicazione {x}"
|
||||
scheduledToPostOnX: "Pubblicazione pianificata {x}"
|
||||
@@ -3052,7 +3054,7 @@ _customEmojisManager:
|
||||
confirmClearEmojisDescription: "Annullare le modifiche e cancella le emoji nell'elenco. Confermi?"
|
||||
confirmUploadEmojisDescription: "Caricamento sul Drive di {count} file locali. Vuoi davvero procedere?"
|
||||
_embedCodeGen:
|
||||
title: "Personalizza il codice di incorporamento"
|
||||
title: "Personalizza il codice per incorporare"
|
||||
header: "Mostra la testata"
|
||||
autoload: "Carica automaticamente di più (sconsigliato)"
|
||||
maxHeight: "Altezza massima"
|
||||
@@ -3061,8 +3063,8 @@ _embedCodeGen:
|
||||
previewIsNotActual: "Poiché supera l'intervallo che può essere visualizzato in anteprima, la visualizzazione vera e propria sarà diversa quando effettivamente incorporata."
|
||||
rounded: "Bordo arrotondato"
|
||||
border: "Aggiungi un bordo al contenitore"
|
||||
applyToPreview: "Applica all'anteprima"
|
||||
generateCode: "Crea il codice di incorporamento"
|
||||
applyToPreview: "Aggiorna l'anteprima"
|
||||
generateCode: "Crea il codice per incorporare"
|
||||
codeGenerated: "Codice generato"
|
||||
codeGeneratedDescription: "Incolla il codice appena generato sul tuo sito web."
|
||||
_selfXssPrevention:
|
||||
|
||||
+74
-3
@@ -182,7 +182,7 @@ flagAsCat: "にゃああああああああああああああ!!!!!!
|
||||
flagAsCatDescription: "にゃにゃにゃ??"
|
||||
flagShowTimelineReplies: "タイムラインにノートへの返信を表示する"
|
||||
flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。"
|
||||
autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
|
||||
autoAcceptFollowed: "フォロー中ユーザーからのフォロー申請を自動承認"
|
||||
addAccount: "アカウントを追加"
|
||||
reloadAccountsList: "アカウントリストの情報を更新"
|
||||
loginFailed: "ログインに失敗しました"
|
||||
@@ -302,6 +302,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか
|
||||
uploadNFiles: "{n}個のファイルをアップロード"
|
||||
explore: "みつける"
|
||||
messageRead: "既読"
|
||||
readAllChatMessages: "すべてのメッセージを既読にする"
|
||||
noMoreHistory: "これより過去の履歴はありません"
|
||||
startChat: "メッセージを送る"
|
||||
nUsersRead: "{n}人が読みました"
|
||||
@@ -1022,6 +1023,9 @@ pushNotificationAlreadySubscribed: "プッシュ通知は有効です"
|
||||
pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応"
|
||||
sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する"
|
||||
sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。"
|
||||
pleaseAllowPushNotification: "ブラウザの通知設定を許可してください"
|
||||
browserPushNotificationDisabled: "通知の送信権限の取得に失敗しました"
|
||||
browserPushNotificationDisabledDescription: "{serverName}から通知を送信する権限がありません。ブラウザの設定から通知を許可して再度お試しください。"
|
||||
windowMaximize: "最大化"
|
||||
windowMinimize: "最小化"
|
||||
windowRestore: "元に戻す"
|
||||
@@ -1172,7 +1176,7 @@ installed: "インストール済み"
|
||||
branding: "ブランディング"
|
||||
enableServerMachineStats: "サーバーのマシン情報を公開する"
|
||||
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
|
||||
showRoleBadgesOfRemoteUsers: "リモートユーザーのロールバッジを表示する"
|
||||
showRoleBadgesOfRemoteUsers: "リモートユーザーに付与したロールバッジを表示する"
|
||||
turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。"
|
||||
createInviteCode: "招待コードを作成"
|
||||
createWithOptions: "オプションを指定して作成"
|
||||
@@ -1396,6 +1400,52 @@ scheduled: "予約"
|
||||
widgets: "ウィジェット"
|
||||
deviceInfo: "デバイス情報"
|
||||
deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。"
|
||||
youAreAdmin: "あなたは管理者です"
|
||||
frame: "フレーム"
|
||||
presets: "プリセット"
|
||||
zeroPadding: "ゼロ埋め"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "ファイルのキャプション"
|
||||
filename: "ファイル名"
|
||||
filename_without_ext: "拡張子無しファイル名"
|
||||
year: "撮影年"
|
||||
month: "撮影月"
|
||||
day: "撮影日"
|
||||
hour: "撮影した時刻(時)"
|
||||
minute: "撮影した時刻(分)"
|
||||
second: "撮影した時刻(秒)"
|
||||
camera_model: "カメラ名"
|
||||
camera_lens_model: "レンズ名"
|
||||
camera_mm: "焦点距離"
|
||||
camera_mm_35: "焦点距離(35mm判換算)"
|
||||
camera_f: "絞り"
|
||||
camera_s: "シャッタースピード"
|
||||
camera_iso: "ISO感度"
|
||||
gps_lat: "緯度"
|
||||
gps_long: "経度"
|
||||
|
||||
_imageFrameEditor:
|
||||
title: "フレームの編集"
|
||||
tip: "画像にフレームやメタデータを含んだラベルを追加して装飾できます。"
|
||||
header: "ヘッダー"
|
||||
footer: "フッター"
|
||||
borderThickness: "フチの幅"
|
||||
labelThickness: "ラベルの幅"
|
||||
labelScale: "ラベルのスケール"
|
||||
centered: "中央揃え"
|
||||
captionMain: "キャプション(大)"
|
||||
captionSub: "キャプション(小)"
|
||||
availableVariables: "利用可能な変数"
|
||||
withQrCode: "二次元コード"
|
||||
backgroundColor: "背景色"
|
||||
textColor: "文字色"
|
||||
font: "フォント"
|
||||
fontSerif: "セリフ"
|
||||
fontSansSerif: "サンセリフ"
|
||||
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_compression:
|
||||
_quality:
|
||||
@@ -1503,6 +1553,7 @@ _settings:
|
||||
showUrlPreview: "URLプレビューを表示する"
|
||||
showAvailableReactionsFirstInNote: "利用できるリアクションを先頭に表示"
|
||||
showPageTabBarBottom: "ページのタブバーを下部に表示"
|
||||
emojiPaletteBanner: "絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。"
|
||||
|
||||
_chat:
|
||||
showSenderName: "送信者の名前を表示"
|
||||
@@ -1523,6 +1574,7 @@ _preferencesBackup:
|
||||
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
|
||||
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
|
||||
backupFound: "設定のバックアップが見つかりました"
|
||||
forceBackup: "設定の強制バックアップ"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
@@ -2589,6 +2641,20 @@ _postForm:
|
||||
replyPlaceholder: "このノートに返信..."
|
||||
quotePlaceholder: "このノートを引用..."
|
||||
channelPlaceholder: "チャンネルに投稿..."
|
||||
showHowToUse: "フォームの説明を表示"
|
||||
_howToUse:
|
||||
content_title: "本文"
|
||||
content_description: "投稿する内容を入力します。"
|
||||
toolbar_title: "ツールバー"
|
||||
toolbar_description: "ファイルやアンケートの添付、注釈やハッシュタグの設定、絵文字やメンションの挿入などが行えます。"
|
||||
account_title: "アカウントメニュー"
|
||||
account_description: "投稿するアカウントを切り替えたり、アカウントに保存した下書き・予約投稿を一覧できます。"
|
||||
visibility_title: "公開範囲"
|
||||
visibility_description: "ノートを公開する範囲の設定が行えます。"
|
||||
menu_title: "メニュー"
|
||||
menu_description: "下書きへの保存、投稿の予約、リアクションの設定など、その他のアクションが行えます。"
|
||||
submit_title: "投稿ボタン"
|
||||
submit_description: "ノートを投稿します。Ctrl + Enter / Cmd + Enter でも投稿できます。"
|
||||
_placeholders:
|
||||
a: "いまどうしてる?"
|
||||
b: "何かありましたか?"
|
||||
@@ -2886,6 +2952,8 @@ _abuseReport:
|
||||
deleteConfirm: "通知先を削除しますか?"
|
||||
|
||||
_moderationLogTypes:
|
||||
clearQueue: "ジョブキューをクリア"
|
||||
promoteQueue: "キューのジョブを再試行"
|
||||
createRole: "ロールを作成"
|
||||
deleteRole: "ロールを削除"
|
||||
updateRole: "ロールを更新"
|
||||
@@ -3298,13 +3366,14 @@ _userLists:
|
||||
watermark: "ウォーターマーク"
|
||||
defaultPreset: "デフォルトのプリセット"
|
||||
_watermarkEditor:
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加することができます。"
|
||||
tip: "画像にクレジット情報などのウォーターマークを追加できます。"
|
||||
quitWithoutSaveConfirm: "保存せずに終了しますか?"
|
||||
driveFileTypeWarn: "このファイルは対応していません"
|
||||
driveFileTypeWarnDescription: "画像ファイルを選択してください"
|
||||
title: "ウォーターマークの編集"
|
||||
cover: "全体に被せる"
|
||||
repeat: "敷き詰める"
|
||||
preserveBoundingRect: "回転時はみ出ないように調整する"
|
||||
opacity: "不透明度"
|
||||
scale: "サイズ"
|
||||
text: "テキスト"
|
||||
@@ -3326,12 +3395,14 @@ _watermarkEditor:
|
||||
polkadotSubDotRadius: "サブドットの大きさ"
|
||||
polkadotSubDotDivisions: "サブドットの数"
|
||||
leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_imageEffector:
|
||||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_fxs:
|
||||
chromaticAberration: "色収差"
|
||||
|
||||
@@ -775,6 +775,7 @@ lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공
|
||||
alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정"
|
||||
loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시"
|
||||
disableShowingAnimatedImages: "움직이는 이미지를 자동으로 재생하지 않음"
|
||||
disableShowingAnimatedImages_caption: "이 설정에 상관없이 애니메이션 이미지가 재생되지 않을 때는 브라우저·OS의 액티비티 설정이나 절전 모드 설정 등이 간섭하고 있는 경우가 있습니다."
|
||||
highlightSensitiveMedia: "미디어가 민감한 내용이라는 것을 알기 쉽게 표시"
|
||||
verificationEmailSent: "확인 메일을 발송하였습니다. 설정을 완료하려면 메일에 첨부된 링크를 확인해 주세요."
|
||||
notSet: "설정되지 않음"
|
||||
@@ -1171,6 +1172,7 @@ installed: "설치됨"
|
||||
branding: "브랜딩"
|
||||
enableServerMachineStats: "서버의 머신 사양을 공개하기"
|
||||
enableIdenticonGeneration: "유저마다의 Identicon 생성 유효화"
|
||||
showRoleBadgesOfRemoteUsers: "리모트 유저의 역할 배지 표시"
|
||||
turnOffToImprovePerformance: "이 기능을 끄면 성능이 향상될 수 있습니다."
|
||||
createInviteCode: "초대 코드 생성"
|
||||
createWithOptions: "옵션을 지정하여 생성"
|
||||
@@ -3199,6 +3201,7 @@ _watermarkEditor:
|
||||
title: "워터마크 편집"
|
||||
cover: "전체에 붙이기"
|
||||
repeat: "전면에 깔기"
|
||||
preserveBoundingRect: "회전 시 빠져나오지 않도록 조정"
|
||||
opacity: "불투명도"
|
||||
scale: "크기"
|
||||
text: "텍스트"
|
||||
|
||||
+26
-23
@@ -106,8 +106,8 @@ privacy: "隐私"
|
||||
makeFollowManuallyApprove: "关注请求需要批准"
|
||||
defaultNoteVisibility: "默认可见性"
|
||||
follow: "关注"
|
||||
followRequest: "关注申请"
|
||||
followRequests: "关注申请"
|
||||
followRequest: "申请关注"
|
||||
followRequests: "关注请求"
|
||||
unfollow: "取消关注"
|
||||
followRequestPending: "关注请求待批准"
|
||||
enterEmoji: "输入表情符号"
|
||||
@@ -540,7 +540,7 @@ regenerate: "重新生成"
|
||||
fontSize: "字体大小"
|
||||
mediaListWithOneImageAppearance: "仅一张图片的媒体列表高度"
|
||||
limitTo: "上限为 {x}"
|
||||
noFollowRequests: "没有关注申请"
|
||||
noFollowRequests: "没有关注请求"
|
||||
openImageInNewTab: "在新标签页中打开图片"
|
||||
dashboard: "管理面板"
|
||||
local: "本地"
|
||||
@@ -775,6 +775,7 @@ lockedAccountInfo: "即使启用该功能,只要帖子可见范围不是「仅
|
||||
alwaysMarkSensitive: "默认将媒体文件标记为敏感内容"
|
||||
loadRawImages: "添加附件图像的缩略图时使用原始图像质量"
|
||||
disableShowingAnimatedImages: "不播放动画"
|
||||
disableShowingAnimatedImages_caption: "如果即使关闭了此设置但动画仍无法播放,则可能是浏览器或操作系统的辅助功能设置,又或者是省电设置等产生了干扰。"
|
||||
highlightSensitiveMedia: "高亮显示敏感媒体"
|
||||
verificationEmailSent: "已发送确认电子邮件。请访问电子邮件中的链接以完成设置。"
|
||||
notSet: "未设置"
|
||||
@@ -872,12 +873,12 @@ noMaintainerInformationWarning: "尚未设置管理员信息。"
|
||||
noInquiryUrlWarning: "尚未设置联络地址。"
|
||||
noBotProtectionWarning: "尚未设置 Bot 防御。"
|
||||
configure: "设置"
|
||||
postToGallery: "创建新相册"
|
||||
postToGallery: "创建新图集"
|
||||
postToHashtag: "投稿到这个标签"
|
||||
gallery: "相册"
|
||||
gallery: "图集"
|
||||
recentPosts: "最新发布"
|
||||
popularPosts: "热门投稿"
|
||||
shareWithNote: "分享到贴文"
|
||||
shareWithNote: "分享到帖文"
|
||||
ads: "广告"
|
||||
expiration: "截止时间"
|
||||
startingperiod: "开始时间"
|
||||
@@ -1030,7 +1031,7 @@ tools: "工具"
|
||||
cannotLoad: "无法加载"
|
||||
numberOfProfileView: "个人资料展示次数"
|
||||
like: "点赞!"
|
||||
unlike: "取消赞"
|
||||
unlike: "取消喜欢"
|
||||
numberOfLikes: "点赞数"
|
||||
show: "显示"
|
||||
neverShow: "不再显示"
|
||||
@@ -1066,7 +1067,7 @@ thisPostMayBeAnnoyingHome: "发到首页"
|
||||
thisPostMayBeAnnoyingCancel: "取消"
|
||||
thisPostMayBeAnnoyingIgnore: "就这样发布"
|
||||
collapseRenotes: "省略显示已经看过的转发内容"
|
||||
collapseRenotesDescription: "将回应过或转贴过的贴子折叠表示。"
|
||||
collapseRenotesDescription: "折叠显示回应或转发过的帖文。"
|
||||
internalServerError: "内部服务器错误"
|
||||
internalServerErrorDescription: "内部服务器发生了预期外的错误"
|
||||
copyErrorInfo: "复制错误信息"
|
||||
@@ -1107,7 +1108,7 @@ retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
|
||||
enableChartsForRemoteUser: "生成远程用户的图表"
|
||||
enableChartsForFederatedInstances: "生成远程服务器的图表"
|
||||
enableStatsForFederatedInstances: "获取远程服务器的信息"
|
||||
showClipButtonInNoteFooter: "在贴文下方显示便签按钮"
|
||||
showClipButtonInNoteFooter: "在帖文下方显示便签按钮"
|
||||
reactionsDisplaySize: "回应显示大小"
|
||||
limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示"
|
||||
noteIdOrUrl: "帖子 ID 或 URL"
|
||||
@@ -1145,7 +1146,7 @@ archive: "归档"
|
||||
archived: "已归档"
|
||||
unarchive: "取消归档"
|
||||
channelArchiveConfirmTitle: "要将 {name} 归档吗?"
|
||||
channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。"
|
||||
channelArchiveConfirmDescription: "归档后,不会在频道列表与搜索结果中显示,也无法发布新的帖文。"
|
||||
thisChannelArchived: "该频道已被归档。"
|
||||
displayOfNote: "显示帖子"
|
||||
initialAccountSetting: "初始设定"
|
||||
@@ -1171,6 +1172,7 @@ installed: "已安装"
|
||||
branding: "品牌"
|
||||
enableServerMachineStats: "公开服务器硬件统计信息"
|
||||
enableIdenticonGeneration: "启用生成用户 Identicon"
|
||||
showRoleBadgesOfRemoteUsers: "显示远程用户的角色徽章"
|
||||
turnOffToImprovePerformance: "关闭该选项可以提高性能。"
|
||||
createInviteCode: "生成邀请码"
|
||||
createWithOptions: "使用选项来创建"
|
||||
@@ -1801,7 +1803,7 @@ _achievements:
|
||||
_login500:
|
||||
title: "老熟人Ⅰ"
|
||||
description: "累计登录 500 天"
|
||||
flavor: "诸君,我喜欢贴文"
|
||||
flavor: "诸君,我喜欢帖文"
|
||||
_login600:
|
||||
title: "老熟人Ⅱ"
|
||||
description: "累计登录 600 天"
|
||||
@@ -1820,7 +1822,7 @@ _achievements:
|
||||
flavor: "感谢您使用 Misskey!"
|
||||
_noteClipped1:
|
||||
title: "忍不住要收藏到便签"
|
||||
description: "第一次将贴文贴进便签"
|
||||
description: "第一次将帖子加入便签"
|
||||
_noteFavorited1:
|
||||
title: "观星者"
|
||||
description: "第一次将帖子加入收藏"
|
||||
@@ -2026,7 +2028,7 @@ _role:
|
||||
wordMuteMax: "屏蔽词的字数限制"
|
||||
webhookMax: "Webhook 创建数量限制"
|
||||
clipMax: "便签创建数量限制"
|
||||
noteEachClipsMax: "单个便签内的贴文数量限制"
|
||||
noteEachClipsMax: "便签内贴文的最大数量"
|
||||
userListMax: "用户列表创建数量限制"
|
||||
userEachUserListsMax: "单个用户列表内用户数量限制"
|
||||
rateLimitFactor: "速率限制"
|
||||
@@ -2112,8 +2114,8 @@ _forgotPassword:
|
||||
ifNoEmail: "如果您没有设置电子邮件地址,请联系管理员。"
|
||||
contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。"
|
||||
_gallery:
|
||||
my: "我的相册"
|
||||
liked: "喜欢的相册"
|
||||
my: "我的图集"
|
||||
liked: "喜欢的图集"
|
||||
like: "喜欢!"
|
||||
unlike: "取消喜欢"
|
||||
_email:
|
||||
@@ -2366,10 +2368,10 @@ _permissions:
|
||||
"write:user-groups": "编辑用户组"
|
||||
"read:channels": "查看频道"
|
||||
"write:channels": "管理频道"
|
||||
"read:gallery": "浏览相册"
|
||||
"write:gallery": "编辑相册"
|
||||
"read:gallery-likes": "浏览喜欢的相册"
|
||||
"write:gallery-likes": "管理喜欢的相册"
|
||||
"read:gallery": "浏览图集"
|
||||
"write:gallery": "编辑图集"
|
||||
"read:gallery-likes": "浏览喜欢的图集"
|
||||
"write:gallery-likes": "管理喜欢的图集"
|
||||
"read:flash": "查看 Play"
|
||||
"write:flash": "编辑 Play"
|
||||
"read:flash-likes": "查看 Play 的点赞"
|
||||
@@ -2776,7 +2778,7 @@ _webhookSettings:
|
||||
_events:
|
||||
follow: "关注时"
|
||||
followed: "被关注时"
|
||||
note: "发布贴文时"
|
||||
note: "发布帖文时"
|
||||
reply: "收到回复时"
|
||||
renote: "被转发时"
|
||||
reaction: "被回应时"
|
||||
@@ -2852,7 +2854,7 @@ _moderationLogTypes:
|
||||
deleteAccount: "删除了账户"
|
||||
deletePage: "删除了页面"
|
||||
deleteFlash: "删除了 Play"
|
||||
deleteGalleryPost: "删除相册内容"
|
||||
deleteGalleryPost: "删除图集内容"
|
||||
deleteChatRoom: "删除聊天室"
|
||||
updateProxyAccountDescription: "更新代理账户的简介"
|
||||
_fileViewer:
|
||||
@@ -3072,8 +3074,8 @@ _selfXssPrevention:
|
||||
description2: "如果不能完全理解将要粘贴的内容,%c 请立即停止操作并关闭这个窗口。"
|
||||
description3: "详情请看这里。{link}"
|
||||
_followRequest:
|
||||
recieved: "已收到申请"
|
||||
sent: "已发送申请"
|
||||
recieved: "收到的请求"
|
||||
sent: "已发送的请求"
|
||||
_remoteLookupErrors:
|
||||
_federationNotAllowed:
|
||||
title: "无法与此服务器通信"
|
||||
@@ -3199,6 +3201,7 @@ _watermarkEditor:
|
||||
title: "编辑水印"
|
||||
cover: "覆盖全体"
|
||||
repeat: "平铺"
|
||||
preserveBoundingRect: "调整为旋转时不超出范围"
|
||||
opacity: "不透明度"
|
||||
scale: "大小"
|
||||
text: "文本"
|
||||
|
||||
+3
-1
@@ -775,6 +775,7 @@ lockedAccountInfo: "即使追隨需要核准,除非你將貼文的可見性設
|
||||
alwaysMarkSensitive: "預設標記檔案為敏感內容"
|
||||
loadRawImages: "以原始圖檔顯示附件圖檔的縮圖"
|
||||
disableShowingAnimatedImages: "不播放動態圖檔"
|
||||
disableShowingAnimatedImages_caption: "無論這個設定如何,如果動畫圖片無法播放,可能是因為瀏覽器或作業系統的無障礙設定、省電設定等產生了干擾。"
|
||||
highlightSensitiveMedia: "強調敏感標記"
|
||||
verificationEmailSent: "已發送驗證電子郵件。請點擊進入電子郵件中的連結以完成驗證。"
|
||||
notSet: "未設定"
|
||||
@@ -3175,7 +3176,7 @@ _uploader:
|
||||
abortConfirm: "有些檔案尚未上傳,您要中止嗎?"
|
||||
doneConfirm: "有些檔案尚未上傳,是否要完成上傳?"
|
||||
maxFileSizeIsX: "可上傳的最大檔案大小為 {x}。"
|
||||
allowedTypes: "可上傳的檔案類型"
|
||||
allowedTypes: "可上傳的檔案類型。"
|
||||
tip: "檔案尚未上傳。您可以在此對話框中進行上傳前的確認、重新命名、壓縮、裁切等操作。準備完成後,請點選「上傳」按鈕開始上傳。\n"
|
||||
_clientPerformanceIssueTip:
|
||||
title: "如果覺得電池消耗過快的話"
|
||||
@@ -3199,6 +3200,7 @@ _watermarkEditor:
|
||||
title: "編輯浮水印"
|
||||
cover: "覆蓋整體"
|
||||
repeat: "佈局"
|
||||
preserveBoundingRect: "調整使其在旋轉時不會突出"
|
||||
opacity: "透明度"
|
||||
scale: "大小"
|
||||
text: "文字"
|
||||
|
||||
+13
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.10.1-alpha.2",
|
||||
"version": "2025.11.0-alpha.3",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"packageManager": "pnpm@10.18.2",
|
||||
"workspaces": [
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend",
|
||||
@@ -68,15 +68,15 @@
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "2.1.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "22.18.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@types/node": "24.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.3.0",
|
||||
"eslint": "9.36.0",
|
||||
"cypress": "15.4.0",
|
||||
"eslint": "9.37.0",
|
||||
"globals": "16.4.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "10.17.1",
|
||||
"pnpm": "10.18.2",
|
||||
"start-server-and-test": "2.1.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -85,6 +85,10 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@aiscript-dev/aiscript-languageserver": "-"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@sentry-internal/node-cpu-profiler",
|
||||
"exifreader"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const base = require('./jest.config.cjs')
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
globalSetup: "<rootDir>/test/jest.setup.unit.cjs",
|
||||
testMatch: [
|
||||
"<rootDir>/test/unit/**/*.ts",
|
||||
"<rootDir>/src/**/*.test.ts",
|
||||
|
||||
@@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const args = [];
|
||||
args.push(...[
|
||||
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [],
|
||||
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0 || ^24.10.0') ? ['--no-experimental-require-module'] : [],
|
||||
'--experimental-vm-modules',
|
||||
'--experimental-import-meta-resolve',
|
||||
path.join(__dirname, 'node_modules/jest/bin/jest.js'),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddChannelMuting1761569941833 {
|
||||
name = 'AddChannelMuting1761569941833'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "channel_muting" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "channelId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aec842e98f332ebd8e12f85bad6" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_34415e3062ae7a94617496e81c" ON "channel_muting" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_4d534d7177fc59879d942e96d0" ON "channel_muting" ("channelId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_6dd314e96806b7df65ddadff72" ON "channel_muting" ("expiresAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_b96870ed326ccc7fa243970965" ON "channel_muting" ("userId", "channelId") `);
|
||||
await queryRunner.query(`ALTER TABLE "note" ADD "renoteChannelId" character varying(32)`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_34415e3062ae7a94617496e81c5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_4d534d7177fc59879d942e96d03" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_4d534d7177fc59879d942e96d03"`);
|
||||
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_34415e3062ae7a94617496e81c5"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "renoteChannelId"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_b96870ed326ccc7fa243970965"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_6dd314e96806b7df65ddadff72"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_4d534d7177fc59879d942e96d0"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_34415e3062ae7a94617496e81c"`);
|
||||
await queryRunner.query(`DROP TABLE "channel_muting"`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.15.0"
|
||||
"node": "^22.15.0 || ^24.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./built/boot/entry.js",
|
||||
@@ -69,8 +69,8 @@
|
||||
"utf-8-validate": "6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.901.0",
|
||||
"@aws-sdk/lib-storage": "3.901.0",
|
||||
"@aws-sdk/client-s3": "3.908.0",
|
||||
"@aws-sdk/lib-storage": "3.908.0",
|
||||
"@discordapp/twemoji": "16.0.1",
|
||||
"@fastify/accepts": "5.0.3",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
@@ -87,8 +87,8 @@
|
||||
"@nestjs/core": "11.1.6",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.55.0",
|
||||
"@sentry/profiling-node": "8.55.0",
|
||||
"@sentry/node": "10.20.0",
|
||||
"@sentry/profiling-node": "10.20.0",
|
||||
"@simplewebauthn/server": "12.0.0",
|
||||
"@sinonjs/fake-timers": "11.3.1",
|
||||
"@smithy/node-http-handler": "2.5.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.59.0",
|
||||
"bullmq": "5.61.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.6.2",
|
||||
@@ -117,15 +117,15 @@
|
||||
"fastify": "5.6.1",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "19.6.0",
|
||||
"file-type": "21.0.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.4",
|
||||
"got": "14.4.9",
|
||||
"happy-dom": "20.0.2",
|
||||
"got": "14.5.0",
|
||||
"happy-dom": "20.0.7",
|
||||
"hpagent": "1.2.0",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.3",
|
||||
"ioredis": "5.8.0",
|
||||
"ioredis": "5.8.1",
|
||||
"ip-cidr": "4.0.2",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"is-svg": "5.1.0",
|
||||
@@ -145,7 +145,7 @@
|
||||
"nanoid": "5.1.6",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.10.1",
|
||||
"nodemailer": "7.0.9",
|
||||
"nsfwjs": "4.2.0",
|
||||
"oauth": "0.10.2",
|
||||
"oauth2orize": "1.12.0",
|
||||
@@ -171,11 +171,11 @@
|
||||
"sanitize-html": "2.17.0",
|
||||
"secure-json-parse": "3.0.2",
|
||||
"sharp": "0.33.5",
|
||||
"semver": "7.7.2",
|
||||
"semver": "7.7.3",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.27.10",
|
||||
"systeminformation": "5.27.11",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.5",
|
||||
"tsc-alias": "1.8.16",
|
||||
@@ -191,7 +191,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.20",
|
||||
"@sentry/vue": "9.46.0",
|
||||
"@sentry/vue": "10.20.0",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.39",
|
||||
"@types/accepts": "1.3.7",
|
||||
@@ -210,7 +210,7 @@
|
||||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/nodemailer": "6.4.20",
|
||||
"@types/oauth": "0.9.6",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
@@ -231,8 +231,8 @@
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ChannelFollowingsRepository } from '@/models/_.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js';
|
||||
import { MiChannel } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
@@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
private idService: IdService,
|
||||
@@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||
onModuleInit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* フォローしているチャンネルの一覧を取得する.
|
||||
* @param params
|
||||
* @param [opts]
|
||||
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
|
||||
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
*/
|
||||
@bindThis
|
||||
public async list(
|
||||
params: {
|
||||
requestUserId: MiUser['id'],
|
||||
},
|
||||
opts?: {
|
||||
idOnly?: boolean;
|
||||
joinUser?: boolean;
|
||||
joinBannerFile?: boolean;
|
||||
},
|
||||
): Promise<MiChannel[]> {
|
||||
if (opts?.idOnly) {
|
||||
const q = this.channelFollowingsRepository.createQueryBuilder('channel_following')
|
||||
.select('channel_following.followeeId')
|
||||
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
|
||||
|
||||
return q
|
||||
.getRawMany<{ channel_following_followeeId: string }>()
|
||||
.then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel)));
|
||||
} else {
|
||||
const q = this.channelsRepository.createQueryBuilder('channel')
|
||||
.innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id')
|
||||
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
|
||||
|
||||
if (opts?.joinUser) {
|
||||
q.innerJoinAndSelect('channel.user', 'user');
|
||||
}
|
||||
|
||||
if (opts?.joinBannerFile) {
|
||||
q.leftJoinAndSelect('channel.banner', 'drive_file');
|
||||
}
|
||||
|
||||
return q.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async follow(
|
||||
requestUser: MiLocalUser,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelMutingService {
|
||||
public mutingChannelsCache: RedisKVCache<Set<string>>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
@Inject(DI.channelMutingRepository)
|
||||
private channelMutingRepository: ChannelMutingRepository,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.mutingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'channelMutingChannels', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (userId) => this.channelMutingRepository.find({
|
||||
where: { userId: userId },
|
||||
select: ['channelId'],
|
||||
}).then(xs => new Set(xs.map(x => x.channelId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* ミュートしているチャンネルの一覧を取得する.
|
||||
* @param params
|
||||
* @param [opts]
|
||||
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
|
||||
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
*/
|
||||
@bindThis
|
||||
public async list(
|
||||
params: {
|
||||
requestUserId: MiUser['id'],
|
||||
},
|
||||
opts?: {
|
||||
idOnly?: boolean;
|
||||
joinUser?: boolean;
|
||||
joinBannerFile?: boolean;
|
||||
},
|
||||
): Promise<MiChannel[]> {
|
||||
if (opts?.idOnly) {
|
||||
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
|
||||
.select('channel_muting.channelId')
|
||||
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.where('channel_muting.expiresAt IS NULL')
|
||||
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
|
||||
}));
|
||||
|
||||
return q
|
||||
.getRawMany<{ channel_muting_channelId: string }>()
|
||||
.then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel)));
|
||||
} else {
|
||||
const q = this.channelsRepository.createQueryBuilder('channel')
|
||||
.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id')
|
||||
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.where('channel_muting.expiresAt IS NULL')
|
||||
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
|
||||
}));
|
||||
|
||||
if (opts?.joinUser) {
|
||||
q.innerJoinAndSelect('channel.user', 'user');
|
||||
}
|
||||
|
||||
if (opts?.joinBannerFile) {
|
||||
q.leftJoinAndSelect('channel.banner', 'drive_file');
|
||||
}
|
||||
|
||||
return q.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 期限切れのチャンネルミュート情報を取得する.
|
||||
*
|
||||
* @param [opts]
|
||||
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
* @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない).
|
||||
*/
|
||||
public async findExpiredMutings(opts?: {
|
||||
joinUser?: boolean;
|
||||
joinChannel?: boolean;
|
||||
}): Promise<MiChannelMuting[]> {
|
||||
const now = new Date();
|
||||
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
|
||||
.where('channel_muting.expiresAt < :now', { now });
|
||||
|
||||
if (opts?.joinUser) {
|
||||
q.innerJoinAndSelect('channel_muting.user', 'user');
|
||||
}
|
||||
|
||||
if (opts?.joinChannel) {
|
||||
q.leftJoinAndSelect('channel_muting.channel', 'channel');
|
||||
}
|
||||
|
||||
return q.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 既にミュートされているかどうかをキャッシュから取得する.
|
||||
* @param params
|
||||
* @param params.requestUserId
|
||||
*/
|
||||
@bindThis
|
||||
public async isMuted(params: {
|
||||
requestUserId: MiUser['id'],
|
||||
targetChannelId: MiChannel['id'],
|
||||
}): Promise<boolean> {
|
||||
const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId);
|
||||
return (mutedChannels?.has(params.targetChannelId) ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* チャンネルをミュートする.
|
||||
* @param params
|
||||
* @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限.
|
||||
*/
|
||||
@bindThis
|
||||
public async mute(params: {
|
||||
requestUserId: MiUser['id'],
|
||||
targetChannelId: MiChannel['id'],
|
||||
expiresAt?: Date | null,
|
||||
}): Promise<void> {
|
||||
await this.channelMutingRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
expiresAt: params.expiresAt,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('muteChannel', {
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* チャンネルのミュートを解除する.
|
||||
* @param params
|
||||
*/
|
||||
@bindThis
|
||||
public async unmute(params: {
|
||||
requestUserId: MiUser['id'],
|
||||
targetChannelId: MiChannel['id'],
|
||||
}): Promise<void> {
|
||||
await this.channelMutingRepository.delete({
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('unmuteChannel', {
|
||||
userId: params.requestUserId,
|
||||
channelId: params.targetChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 期限切れのチャンネルミュート情報を削除する.
|
||||
*/
|
||||
@bindThis
|
||||
public async eraseExpiredMutings(): Promise<void> {
|
||||
const expiredMutings = await this.findExpiredMutings();
|
||||
await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) });
|
||||
|
||||
const userIds = [...new Set(expiredMutings.map(x => x.userId))];
|
||||
for (const userId of userIds) {
|
||||
this.mutingChannelsCache.refresh(userId).then();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'muteChannel': {
|
||||
this.mutingChannelsCache.refresh(body.userId).then();
|
||||
break;
|
||||
}
|
||||
case 'unmuteChannel': {
|
||||
this.mutingChannelsCache.delete(body.userId).then();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.mutingChannelsCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
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';
|
||||
@@ -225,6 +226,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
|
||||
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
||||
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
||||
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
||||
const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService };
|
||||
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
|
||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||
@@ -378,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChannelMutingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
@@ -527,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChannelMutingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
@@ -677,6 +681,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChannelMutingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
@@ -824,6 +829,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChannelMutingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
|
||||
@@ -517,40 +517,43 @@ export class DriveService {
|
||||
this.registerLogger.debug(`ADD DRIVE FILE: user ${user?.id ?? 'not set'}, name ${detectedName}, tmp ${path}`);
|
||||
|
||||
//#region Check drive usage and mime type
|
||||
if (user && !isLink) {
|
||||
if (user != null && !isLink) {
|
||||
const isLocalUser = this.userEntityService.isLocalUser(user);
|
||||
const policies = await this.roleService.getUserPolicies(user.id);
|
||||
const isModerator = isLocalUser ? await this.roleService.isModerator(user) : false;
|
||||
if (!isModerator) {
|
||||
const policies = await this.roleService.getUserPolicies(user.id);
|
||||
|
||||
const allowedMimeTypes = policies.uploadableFileTypes;
|
||||
const isAllowed = allowedMimeTypes.some((mimeType) => {
|
||||
if (mimeType === '*' || mimeType === '*/*') return true;
|
||||
if (mimeType.endsWith('/*')) return info.type.mime.startsWith(mimeType.slice(0, -1));
|
||||
return info.type.mime === mimeType;
|
||||
});
|
||||
if (!isAllowed) {
|
||||
throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', `Unallowed file type: ${info.type.mime}`);
|
||||
}
|
||||
|
||||
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
|
||||
const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb;
|
||||
|
||||
if (maxFileSize < info.size) {
|
||||
if (isLocalUser) {
|
||||
throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.');
|
||||
const allowedMimeTypes = policies.uploadableFileTypes;
|
||||
const isAllowed = allowedMimeTypes.some((mimeType) => {
|
||||
if (mimeType === '*' || mimeType === '*/*') return true;
|
||||
if (mimeType.endsWith('/*')) return info.type.mime.startsWith(mimeType.slice(0, -1));
|
||||
return info.type.mime === mimeType;
|
||||
});
|
||||
if (!isAllowed) {
|
||||
throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', `Unallowed file type: ${info.type.mime}`);
|
||||
}
|
||||
}
|
||||
|
||||
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
|
||||
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
|
||||
const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb;
|
||||
|
||||
this.registerLogger.debug('drive capacity override applied');
|
||||
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
|
||||
|
||||
// If usage limit exceeded
|
||||
if (driveCapacity < usage + info.size) {
|
||||
if (isLocalUser) {
|
||||
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
|
||||
if (maxFileSize < info.size) {
|
||||
if (isLocalUser) {
|
||||
throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.');
|
||||
}
|
||||
}
|
||||
|
||||
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
|
||||
|
||||
this.registerLogger.debug('drive capacity override applied');
|
||||
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
|
||||
|
||||
// If usage limit exceeded
|
||||
if (driveCapacity < usage + info.size) {
|
||||
if (isLocalUser) {
|
||||
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
|
||||
}
|
||||
await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size);
|
||||
}
|
||||
await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size);
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -19,6 +19,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||
|
||||
type NoteFilter = (note: MiNote) => boolean;
|
||||
|
||||
@@ -35,6 +37,7 @@ type TimelineOptions = {
|
||||
ignoreAuthorFromBlock?: boolean;
|
||||
ignoreAuthorFromMute?: boolean;
|
||||
ignoreAuthorFromInstanceBlock?: boolean;
|
||||
ignoreAuthorChannelFromMute?: boolean;
|
||||
excludeNoFiles?: boolean;
|
||||
excludeReplies?: boolean;
|
||||
excludePureRenotes: boolean;
|
||||
@@ -55,6 +58,7 @@ export class FanoutTimelineEndpointService {
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private utilityService: UtilityService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -111,11 +115,13 @@ export class FanoutTimelineEndpointService {
|
||||
userIdsWhoMeMutingRenotes,
|
||||
userIdsWhoBlockingMe,
|
||||
userMutedInstances,
|
||||
userMutedChannels,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(ps.me.id),
|
||||
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
|
||||
this.cacheService.userBlockedCache.fetch(ps.me.id),
|
||||
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
|
||||
this.channelMutingService.mutingChannelsCache.fetch(me.id),
|
||||
]);
|
||||
|
||||
const parentFilter = filter;
|
||||
@@ -126,6 +132,7 @@ export class FanoutTimelineEndpointService {
|
||||
if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
|
||||
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
|
||||
if (isInstanceMuted(note, userMutedInstances)) return false;
|
||||
if (isChannelRelated(note, userMutedChannels, ps.ignoreAuthorChannelFromMute)) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
|
||||
@@ -20,8 +20,8 @@ import { AiService } from '@/core/AiService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { PredictionType } from 'nsfwjs';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import type { PredictionType } from 'nsfwjs';
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
@@ -339,7 +339,7 @@ export class FileInfoService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public fixMime(mime: string | fileType.MimeType): string {
|
||||
public fixMime(mime: string): string {
|
||||
// see https://github.com/misskey-dev/misskey/pull/10686
|
||||
if (mime === 'audio/x-flac') {
|
||||
return 'audio/flac';
|
||||
|
||||
@@ -255,6 +255,8 @@ export interface InternalEventTypes {
|
||||
metaUpdated: { before?: MiMeta; after: MiMeta; };
|
||||
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
updateUserProfile: MiUserProfile;
|
||||
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
|
||||
@@ -604,6 +604,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
replyUserHost: data.reply ? data.reply.userHost : null,
|
||||
renoteUserId: data.renote ? data.renote.userId : null,
|
||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||
renoteChannelId: data.renote ? data.renote.channelId : null,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteChannelId: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,36 +4,40 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js';
|
||||
import type {
|
||||
ChannelFavoritesRepository,
|
||||
ChannelFollowingsRepository, ChannelMutingRepository,
|
||||
ChannelsRepository,
|
||||
DriveFilesRepository,
|
||||
MiDriveFile,
|
||||
MiNote,
|
||||
NotesRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { NoteEntityService } from './NoteEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelEntityService {
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFavoritesRepository)
|
||||
private channelFavoritesRepository: ChannelFavoritesRepository,
|
||||
|
||||
@Inject(DI.channelMutingRepository)
|
||||
private channelMutingRepository: ChannelMutingRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
@@ -45,31 +49,59 @@ export class ChannelEntityService {
|
||||
src: MiChannel['id'] | MiChannel,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
detailed?: boolean,
|
||||
opts?: {
|
||||
bannerFiles?: Map<MiDriveFile['id'], MiDriveFile>;
|
||||
followings?: Set<MiChannel['id']>;
|
||||
favorites?: Set<MiChannel['id']>;
|
||||
muting?: Set<MiChannel['id']>;
|
||||
pinnedNotes?: Map<MiNote['id'], MiNote>;
|
||||
},
|
||||
): Promise<Packed<'Channel'>> {
|
||||
const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src });
|
||||
const meId = me ? me.id : null;
|
||||
|
||||
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
|
||||
let bannerFile: MiDriveFile | null = null;
|
||||
if (channel.bannerId) {
|
||||
bannerFile = opts?.bannerFiles?.get(channel.bannerId)
|
||||
?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId });
|
||||
}
|
||||
|
||||
const isFollowing = meId ? await this.channelFollowingsRepository.exists({
|
||||
where: {
|
||||
followerId: meId,
|
||||
followeeId: channel.id,
|
||||
},
|
||||
}) : false;
|
||||
let isFollowing = false;
|
||||
let isFavorited = false;
|
||||
let isMuting = false;
|
||||
if (me) {
|
||||
isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
followeeId: channel.id,
|
||||
},
|
||||
});
|
||||
|
||||
const isFavorited = meId ? await this.channelFavoritesRepository.exists({
|
||||
where: {
|
||||
userId: meId,
|
||||
channelId: channel.id,
|
||||
},
|
||||
}) : false;
|
||||
isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({
|
||||
where: {
|
||||
userId: me.id,
|
||||
channelId: channel.id,
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: {
|
||||
id: In(channel.pinnedNoteIds),
|
||||
},
|
||||
}) : [];
|
||||
isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({
|
||||
where: {
|
||||
userId: me.id,
|
||||
channelId: channel.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const pinnedNotes = Array.of<MiNote>();
|
||||
if (channel.pinnedNoteIds.length > 0) {
|
||||
pinnedNotes.push(
|
||||
...(
|
||||
opts?.pinnedNotes
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null)
|
||||
: await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) })
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: channel.id,
|
||||
@@ -78,7 +110,8 @@ export class ChannelEntityService {
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
userId: channel.userId,
|
||||
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
|
||||
bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null,
|
||||
bannerId: channel.bannerId,
|
||||
pinnedNoteIds: channel.pinnedNoteIds,
|
||||
color: channel.color,
|
||||
isArchived: channel.isArchived,
|
||||
@@ -90,6 +123,7 @@ export class ChannelEntityService {
|
||||
...(me ? {
|
||||
isFollowing,
|
||||
isFavorited,
|
||||
isMuting,
|
||||
hasUnreadNote: false, // 後方互換性のため
|
||||
} : {}),
|
||||
|
||||
@@ -98,5 +132,72 @@ export class ChannelEntityService {
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
src: MiChannel['id'][] | MiChannel[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
detailed?: boolean,
|
||||
): Promise<Packed<'Channel'>[]> {
|
||||
// IDのみの要素がある場合、DBからオブジェクトを取得して補う
|
||||
const channels = src.filter(it => typeof it === 'object') as MiChannel[];
|
||||
channels.push(
|
||||
...(await this.channelsRepository.find({
|
||||
where: {
|
||||
id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]),
|
||||
},
|
||||
})),
|
||||
);
|
||||
channels.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const bannerFiles = await this.driveFilesRepository
|
||||
.findBy({
|
||||
id: In(channels.map(it => it.bannerId).filter(it => it != null)),
|
||||
})
|
||||
.then(it => new Map(it.map(it => [it.id, it])));
|
||||
|
||||
const followings = me
|
||||
? await this.channelFollowingsRepository
|
||||
.findBy({
|
||||
followerId: me.id,
|
||||
followeeId: In(channels.map(it => it.id)),
|
||||
})
|
||||
.then(it => new Set(it.map(it => it.followeeId)))
|
||||
: new Set<MiChannel['id']>();
|
||||
|
||||
const favorites = me
|
||||
? await this.channelFavoritesRepository
|
||||
.findBy({
|
||||
userId: me.id,
|
||||
channelId: In(channels.map(it => it.id)),
|
||||
})
|
||||
.then(it => new Set(it.map(it => it.channelId)))
|
||||
: new Set<MiChannel['id']>();
|
||||
|
||||
const muting = me
|
||||
? await this.channelMutingRepository
|
||||
.findBy({
|
||||
userId: me.id,
|
||||
channelId: In(channels.map(it => it.id)),
|
||||
})
|
||||
.then(it => new Set(it.map(it => it.channelId)))
|
||||
: new Set<MiChannel['id']>();
|
||||
|
||||
const pinnedNotes = await this.notesRepository
|
||||
.find({
|
||||
where: {
|
||||
id: In(channels.flatMap(it => it.pinnedNoteIds)),
|
||||
},
|
||||
})
|
||||
.then(it => new Map(it.map(it => [it.id, it])));
|
||||
|
||||
return Promise.all(channels.map(it => this.pack(it, me, detailed, {
|
||||
bannerFiles,
|
||||
followings,
|
||||
favorites,
|
||||
muting,
|
||||
pinnedNotes,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export const DI = {
|
||||
channelsRepository: Symbol('channelsRepository'),
|
||||
channelFollowingsRepository: Symbol('channelFollowingsRepository'),
|
||||
channelFavoritesRepository: Symbol('channelFavoritesRepository'),
|
||||
channelMutingRepository: Symbol('channelMutingRepository'),
|
||||
registryItemsRepository: Symbol('registryItemsRepository'),
|
||||
webhooksRepository: Symbol('webhooksRepository'),
|
||||
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
/**
|
||||
* {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。
|
||||
* 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。
|
||||
*
|
||||
* @param note 確認対象のノート
|
||||
* @param channelIds 確認対象のチャンネルID一覧
|
||||
* @param ignoreAuthor trueの場合、ノートの所属チャンネルが{@link channelIds}に含まれていても無視します(デフォルトはfalse)
|
||||
*/
|
||||
export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set<string>, ignoreAuthor = false): boolean {
|
||||
// ノートの所属チャンネルが確認対象のチャンネルID一覧に含まれている場合
|
||||
if (!ignoreAuthor && note.channelId && channelIds.has(note.channelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const renoteChannelId = note.renote?.channelId;
|
||||
if (renoteChannelId != null && renoteChannelId !== note.channelId && channelIds.has(renoteChannelId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので)
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiChannel } from './Channel.js';
|
||||
|
||||
@Entity('channel_muting')
|
||||
@Index(['userId', 'channelId'], {})
|
||||
export class MiChannelMuting {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(type => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public channelId: MiChannel['id'];
|
||||
|
||||
@ManyToOne(type => MiChannel, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: MiChannel | null;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public expiresAt: Date | null;
|
||||
}
|
||||
@@ -248,6 +248,14 @@ export class MiNote {
|
||||
})
|
||||
public renoteUserHost: string | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: '[Denormalized]',
|
||||
})
|
||||
public renoteChannelId: MiChannel['id'] | null;
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MiChannel,
|
||||
MiChannelFavorite,
|
||||
MiChannelFollowing,
|
||||
MiChannelMuting,
|
||||
MiClip,
|
||||
MiClipFavorite,
|
||||
MiClipNote,
|
||||
@@ -429,6 +430,12 @@ const $channelFavoritesRepository: Provider = {
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelMutingRepository: Provider = {
|
||||
provide: DI.channelMutingRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository<MiChannelMuting>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $registryItemsRepository: Provider = {
|
||||
provide: DI.registryItemsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository<MiRegistryItem>),
|
||||
@@ -597,6 +604,7 @@ const $reversiGamesRepository: Provider = {
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelFavoritesRepository,
|
||||
$channelMutingRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$systemWebhooksRepository,
|
||||
@@ -674,6 +682,7 @@ const $reversiGamesRepository: Provider = {
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelFavoritesRepository,
|
||||
$channelMutingRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$systemWebhooksRepository,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiChannel } from '@/models/Channel.js';
|
||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiChatMessage } from '@/models/ChatMessage.js';
|
||||
import { MiChatRoom } from '@/models/ChatRoom.js';
|
||||
@@ -172,6 +173,7 @@ export {
|
||||
MiBlocking,
|
||||
MiChannelFollowing,
|
||||
MiChannelFavorite,
|
||||
MiChannelMuting,
|
||||
MiClip,
|
||||
MiClipNote,
|
||||
MiClipFavorite,
|
||||
@@ -251,6 +253,7 @@ export type AuthSessionsRepository = Repository<MiAuthSession> & MiRepository<Mi
|
||||
export type BlockingsRepository = Repository<MiBlocking> & MiRepository<MiBlocking>;
|
||||
export type ChannelFollowingsRepository = Repository<MiChannelFollowing> & MiRepository<MiChannelFollowing>;
|
||||
export type ChannelFavoritesRepository = Repository<MiChannelFavorite> & MiRepository<MiChannelFavorite>;
|
||||
export type ChannelMutingRepository = Repository<MiChannelMuting> & MiRepository<MiChannelMuting>;
|
||||
export type ClipsRepository = Repository<MiClip> & MiRepository<MiClip>;
|
||||
export type ClipNotesRepository = Repository<MiClipNote> & MiRepository<MiClipNote>;
|
||||
export type ClipFavoritesRepository = Repository<MiClipFavorite> & MiRepository<MiClipFavorite>;
|
||||
|
||||
@@ -40,6 +40,11 @@ export const packedChannelSchema = {
|
||||
format: 'url',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
bannerId: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
format: 'id',
|
||||
},
|
||||
pinnedNoteIds: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
@@ -80,6 +85,10 @@ export const packedChannelSchema = {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
isMuting: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
pinnedNotes: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
|
||||
@@ -174,6 +174,7 @@ export const packedPageSchema = {
|
||||
font: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['serif', 'sans-serif'],
|
||||
},
|
||||
script: {
|
||||
type: 'string',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { MiAuthSession } from '@/models/AuthSession.js';
|
||||
import { MiBlocking } from '@/models/Blocking.js';
|
||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||
import { MiClip } from '@/models/Clip.js';
|
||||
import { MiClipNote } from '@/models/ClipNote.js';
|
||||
import { MiClipFavorite } from '@/models/ClipFavorite.js';
|
||||
@@ -239,6 +240,7 @@ export const entities = [
|
||||
MiChannel,
|
||||
MiChannelFollowing,
|
||||
MiChannelFavorite,
|
||||
MiChannelMuting,
|
||||
MiRegistryItem,
|
||||
MiAd,
|
||||
MiPasswordResetRequest,
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MutingsRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserMutingService } from '@/core/UserMutingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CheckExpiredMutingsProcessorService {
|
||||
@@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService {
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private userMutingService: UserMutingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
|
||||
@@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService {
|
||||
await this.userMutingService.unmute(expired);
|
||||
}
|
||||
|
||||
await this.channelMutingService.eraseExpiredMutings();
|
||||
|
||||
this.logger.succ('All expired mutings checked.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,17 @@ export class CleanRemoteNotesProcessorService {
|
||||
skipped: boolean;
|
||||
transientErrors: number;
|
||||
}> {
|
||||
const getConfig = () => {
|
||||
return {
|
||||
enabled: this.meta.enableRemoteNotesCleaning,
|
||||
maxDuration: this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000, // Convert minutes to milliseconds
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
newestLimit: this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)),
|
||||
};
|
||||
};
|
||||
|
||||
const initialConfig = getConfig();
|
||||
if (!this.meta.enableRemoteNotesCleaning) {
|
||||
this.logger.info('Remote notes cleaning is disabled, skipping...');
|
||||
return {
|
||||
@@ -64,13 +75,9 @@ export class CleanRemoteNotesProcessorService {
|
||||
|
||||
this.logger.info('cleaning remote notes...');
|
||||
|
||||
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
|
||||
const startAt = Date.now();
|
||||
|
||||
//#region queries
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
||||
|
||||
// The condition for removing the notes.
|
||||
// The note must be:
|
||||
@@ -92,7 +99,7 @@ export class CleanRemoteNotesProcessorService {
|
||||
const minId = (await this.notesRepository.createQueryBuilder('note')
|
||||
.select('MIN(note.id)', 'minId')
|
||||
.where({
|
||||
id: LessThan(newestLimit),
|
||||
id: LessThan(initialConfig.newestLimit),
|
||||
userHost: Not(IsNull()),
|
||||
replyId: IsNull(),
|
||||
renoteId: IsNull(),
|
||||
@@ -155,12 +162,12 @@ export class CleanRemoteNotesProcessorService {
|
||||
// | fff | fff | TRUE |
|
||||
// | ggg | ggg | FALSE |
|
||||
//
|
||||
const candidateNotesQuery = this.db.createQueryBuilder()
|
||||
const candidateNotesQuery = ({ limit }: { limit: number }) => this.db.createQueryBuilder()
|
||||
.select(`"${candidateNotesCteName}"."id"`, 'id')
|
||||
.addSelect('unremovable."id" IS NULL', 'isRemovable')
|
||||
.addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase')
|
||||
.addCommonTableExpression(
|
||||
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
|
||||
`((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(limit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`,
|
||||
candidateNotesCteName,
|
||||
{ recursive: true },
|
||||
)
|
||||
@@ -178,6 +185,11 @@ export class CleanRemoteNotesProcessorService {
|
||||
let lowThroughputWarned = false;
|
||||
let transientErrors = 0;
|
||||
for (;;) {
|
||||
const { enabled, maxDuration, newestLimit } = getConfig();
|
||||
if (!enabled) {
|
||||
this.logger.info('Remote notes cleaning is disabled, processing stopped...');
|
||||
break;
|
||||
}
|
||||
//#region check time
|
||||
const batchBeginAt = Date.now();
|
||||
|
||||
@@ -205,13 +217,38 @@ export class CleanRemoteNotesProcessorService {
|
||||
let noteIds = null;
|
||||
|
||||
try {
|
||||
noteIds = await candidateNotesQuery.setParameters(
|
||||
noteIds = await candidateNotesQuery({ limit: currentLimit }).setParameters(
|
||||
{ newestLimit, cursorLeft },
|
||||
).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>();
|
||||
} catch (e) {
|
||||
if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') {
|
||||
// Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again
|
||||
// continuous failures will eventually converge to currentLimit == minimumLimit and then throw
|
||||
if (e instanceof QueryFailedError && e.driverError?.code === '57014') {
|
||||
// Statement timeout (maybe suddenly hit a large note tree), if possible, reduce the limit and try again
|
||||
// if not possible, skip the current batch of notes and find the next root note
|
||||
if (currentLimit <= minimumLimit) {
|
||||
job.log('Local note tree complexity is too high, finding next root note...');
|
||||
|
||||
const idWindow = await this.notesRepository.createQueryBuilder('note')
|
||||
.select('id')
|
||||
.where('note.id > :cursorLeft')
|
||||
.andWhere(removalCriteria)
|
||||
.andWhere({ replyId: IsNull(), renoteId: IsNull() })
|
||||
.orderBy('note.id', 'ASC')
|
||||
.limit(minimumLimit + 1)
|
||||
.setParameters({ cursorLeft, newestLimit })
|
||||
.getRawMany<{ id?: MiNote['id'] }>();
|
||||
|
||||
job.log(`Skipped note IDs: ${idWindow.slice(0, minimumLimit).map(id => id.id).join(', ')}`);
|
||||
|
||||
const lastId = idWindow.at(minimumLimit)?.id;
|
||||
|
||||
if (!lastId) {
|
||||
job.log('No more notes to clean.');
|
||||
break;
|
||||
}
|
||||
|
||||
cursorLeft = lastId;
|
||||
continue;
|
||||
}
|
||||
currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { UserService } from '@/core/UserService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/Connection.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
@@ -39,6 +40,7 @@ export class StreamingApiServerService {
|
||||
private notificationService: NotificationService,
|
||||
private usersService: UserService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -97,6 +99,7 @@ export class StreamingApiServerService {
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
this.channelFollowingService,
|
||||
this.channelMutingService,
|
||||
user, app,
|
||||
);
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ export * as 'channels/timeline' from './endpoints/channels/timeline.js';
|
||||
export * as 'channels/unfavorite' from './endpoints/channels/unfavorite.js';
|
||||
export * as 'channels/unfollow' from './endpoints/channels/unfollow.js';
|
||||
export * as 'channels/update' from './endpoints/channels/update.js';
|
||||
export * as 'channels/mute/create' from './endpoints/channels/mute/create.js';
|
||||
export * as 'channels/mute/delete' from './endpoints/channels/mute/delete.js';
|
||||
export * as 'channels/mute/list' from './endpoints/channels/mute/list.js';
|
||||
export * as 'charts/active-users' from './endpoints/charts/active-users.js';
|
||||
export * as 'charts/ap-request' from './endpoints/charts/ap-request.js';
|
||||
export * as 'charts/drive' from './endpoints/charts/drive.js';
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
@@ -14,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -69,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -108,6 +111,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
// -- ミュートされたチャンネル対策
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
|
||||
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'mute'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such Channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: '7174361e-d58f-31d6-2e7c-6fb830786a3f',
|
||||
},
|
||||
|
||||
alreadyMuting: {
|
||||
message: 'You are already muting that user.',
|
||||
code: 'ALREADY_MUTING_CHANNEL',
|
||||
id: '5a251978-769a-da44-3e89-3931e43bb592',
|
||||
},
|
||||
|
||||
expiresAtIsPast: {
|
||||
message: 'Cannot set past date to "expiresAt".',
|
||||
code: 'EXPIRES_AT_IS_PAST',
|
||||
id: '42b32236-df2c-a45f-fdbf-def67268f749',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: { type: 'string', format: 'misskey:id' },
|
||||
expiresAt: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.',
|
||||
},
|
||||
},
|
||||
required: ['channelId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check if exists the channel
|
||||
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||
if (!targetChannel) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
// Check if already muting
|
||||
const exist = await this.channelMutingService.isMuted({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
if (exist) {
|
||||
throw new ApiError(meta.errors.alreadyMuting);
|
||||
}
|
||||
|
||||
// Check if expiresAt is past
|
||||
if (ps.expiresAt && ps.expiresAt <= Date.now()) {
|
||||
throw new ApiError(meta.errors.expiresAtIsPast);
|
||||
}
|
||||
|
||||
await this.channelMutingService.mute({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'mute'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
noSuchChannel: {
|
||||
message: 'No such Channel.',
|
||||
code: 'NO_SUCH_CHANNEL',
|
||||
id: 'e7998769-6e94-d9c2-6b8f-94a527314aba',
|
||||
},
|
||||
|
||||
notMuting: {
|
||||
message: 'You are not muting that channel.',
|
||||
code: 'NOT_MUTING_CHANNEL',
|
||||
id: '14d55962-6ea8-d990-1333-d6bef78dc2ab',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['channelId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check if exists the channel
|
||||
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||
if (!targetChannel) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
// Check muting
|
||||
const exist = await this.channelMutingService.isMuted({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
if (!exist) {
|
||||
throw new ApiError(meta.errors.notMuting);
|
||||
}
|
||||
|
||||
await this.channelMutingService.unmute({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels', 'mute'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'read:channels',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private channelEntityService: ChannelEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const mutings = await this.channelMutingService.list({
|
||||
requestUserId: me.id,
|
||||
});
|
||||
return await this.channelEntityService.packMany(mutings, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -70,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -98,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
useDbFallback: true,
|
||||
redisTimelines: [`channelTimeline:${channel.id}`],
|
||||
excludePureRenotes: false,
|
||||
ignoreAuthorChannelFromMute: true,
|
||||
dbFallback: async (untilId, sinceId, limit) => {
|
||||
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
||||
},
|
||||
@@ -122,6 +125,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
if (me) {
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id).filter(x => x !== ps.channelId));
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
||||
@@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -46,7 +48,7 @@ export const meta = {
|
||||
bothWithRepliesAndWithFiles: {
|
||||
message: 'Specifying both withReplies and withFiles is not supported',
|
||||
code: 'BOTH_WITH_REPLIES_AND_WITH_FILES',
|
||||
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f'
|
||||
id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -79,9 +81,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
@@ -89,6 +88,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private cacheService: CacheService,
|
||||
private queryService: QueryService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
@@ -196,11 +197,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
withReplies: boolean,
|
||||
}, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
const followingChannelIds = await this.channelFollowingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
@@ -219,9 +222,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followingChannels.length > 0) {
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
|
||||
if (followingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
@@ -230,6 +231,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
if (!ps.withReplies) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
|
||||
@@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -76,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -157,7 +159,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
const mutedChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutedChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL')
|
||||
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
query.andWhere('note.fileIds != \'{}\'');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js';
|
||||
import type { NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
@@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -61,15 +63,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
@@ -140,11 +141,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) {
|
||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||
const followingChannels = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
const followingChannelIds = await this.channelFollowingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x)));
|
||||
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
@@ -154,15 +157,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followees.length > 0 && followingChannels.length > 0) {
|
||||
if (followees.length > 0 && followingChannelIds.length > 0) {
|
||||
// ユーザー・チャンネルともにフォローあり
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb2 => {
|
||||
qb2
|
||||
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
|
||||
.andWhere('note.channelId IS NULL');
|
||||
}))
|
||||
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
|
||||
@@ -170,22 +172,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
} else if (followees.length > 0) {
|
||||
// ユーザーフォローのみ(チャンネルフォローなし)
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
query
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||
} else if (followingChannels.length > 0) {
|
||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
||||
const followingChannelIds = followingChannels.map(x => x.followeeId);
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||
if (mutingChannelIds.length > 0) {
|
||||
qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}
|
||||
}));
|
||||
} else if (followingChannelIds.length > 0) {
|
||||
// チャンネルフォローのみ(ユーザーフォローなし)
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// renoteChannelIdは見る必要が無い
|
||||
// ・HTLに流れてくるチャンネル=フォローしているチャンネル
|
||||
// ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ
|
||||
// つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い
|
||||
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
|
||||
.orWhere('note.userId = :meId', { meId: me.id });
|
||||
}));
|
||||
} else {
|
||||
// フォローなし
|
||||
query
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
}));
|
||||
}
|
||||
|
||||
query.andWhere(new Brackets(qb => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -84,6 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private queryService: QueryService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -187,6 +189,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
||||
// -- ミュートされたチャンネルのリノート対策
|
||||
const mutedChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutedChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL')
|
||||
.orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
if (ps.includeMyRenotes === false) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { NotesRepository, RolesRepository } from '@/models/_.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
@@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -68,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -101,6 +104,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
// -- ミュートされたチャンネル対策
|
||||
const mutingChannelIds = await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id));
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users', 'notes'],
|
||||
@@ -77,12 +78,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private idService: IdService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -165,6 +166,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
withFiles: boolean,
|
||||
withRenotes: boolean,
|
||||
}, me: MiLocalUser | null) {
|
||||
const mutingChannelIds = me
|
||||
? await this.channelMutingService
|
||||
.list({ requestUserId: me.id }, { idOnly: true })
|
||||
.then(x => x.map(x => x.id))
|
||||
: [];
|
||||
const isSelf = me && (me.id === ps.userId);
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
@@ -177,14 +183,30 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (ps.withChannelNotes) {
|
||||
if (!isSelf) query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.channelId IS NULL');
|
||||
qb.orWhere('channel.isSensitive = false');
|
||||
query.andWhere(new Brackets(qb => {
|
||||
if (mutingChannelIds.length > 0) {
|
||||
qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds });
|
||||
}
|
||||
|
||||
if (!isSelf) {
|
||||
qb.andWhere(new Brackets(qb2 => {
|
||||
qb2.orWhere('note.channelId IS NULL');
|
||||
qb2.orWhere('channel.isSensitive = false');
|
||||
}));
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
query.andWhere('note.channelId IS NULL');
|
||||
}
|
||||
|
||||
// -- ミュートされたチャンネルのリノート対策
|
||||
if (mutingChannelIds.length > 0) {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteChannelId IS NULL');
|
||||
qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds });
|
||||
}));
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me, {
|
||||
excludeAuthor: true,
|
||||
|
||||
@@ -11,8 +11,9 @@ import type { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
||||
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import type { ChannelsService } from './ChannelsService.js';
|
||||
@@ -35,6 +36,7 @@ export default class Connection {
|
||||
public userProfile: MiUserProfile | null = null;
|
||||
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
public followingChannels: Set<string> = new Set();
|
||||
public mutingChannels: Set<string> = new Set();
|
||||
public userIdsWhoMeMuting: Set<string> = new Set();
|
||||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
|
||||
@@ -46,7 +48,7 @@ export default class Connection {
|
||||
private notificationService: NotificationService,
|
||||
private cacheService: CacheService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
|
||||
private channelMutingService: ChannelMutingService,
|
||||
user: MiUser | null | undefined,
|
||||
token: MiAccessToken | null | undefined,
|
||||
) {
|
||||
@@ -57,10 +59,19 @@ export default class Connection {
|
||||
@bindThis
|
||||
public async fetch() {
|
||||
if (this.user == null) return;
|
||||
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
|
||||
const [
|
||||
userProfile,
|
||||
following,
|
||||
followingChannels,
|
||||
mutingChannels,
|
||||
userIdsWhoMeMuting,
|
||||
userIdsWhoBlockingMe,
|
||||
userIdsWhoMeMutingRenotes,
|
||||
] = await Promise.all([
|
||||
this.cacheService.userProfileCache.fetch(this.user.id),
|
||||
this.cacheService.userFollowingsCache.fetch(this.user.id),
|
||||
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
|
||||
this.channelMutingService.mutingChannelsCache.fetch(this.user.id),
|
||||
this.cacheService.userMutingsCache.fetch(this.user.id),
|
||||
this.cacheService.userBlockedCache.fetch(this.user.id),
|
||||
this.cacheService.renoteMutingsCache.fetch(this.user.id),
|
||||
@@ -68,6 +79,7 @@ export default class Connection {
|
||||
this.userProfile = userProfile;
|
||||
this.following = following;
|
||||
this.followingChannels = followingChannels;
|
||||
this.mutingChannels = mutingChannels;
|
||||
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
|
||||
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
|
||||
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
|
||||
import { isChannelRelated } from '@/misc/is-channel-related.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import type Connection from './Connection.js';
|
||||
@@ -55,6 +56,10 @@ export default abstract class Channel {
|
||||
return this.connection.followingChannels;
|
||||
}
|
||||
|
||||
protected get mutingChannels() {
|
||||
return this.connection.mutingChannels;
|
||||
}
|
||||
|
||||
protected get subscriber() {
|
||||
return this.connection.subscriber;
|
||||
}
|
||||
@@ -74,6 +79,9 @@ export default abstract class Channel {
|
||||
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||
|
||||
// 流れてきたNoteがミュートしているチャンネルと関わる
|
||||
if (isChannelRelated(note, this.mutingChannels)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
|
||||
@@ -19,7 +21,6 @@ class ChannelChannel extends Channel {
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
@@ -52,6 +53,35 @@ class ChannelChannel extends Channel {
|
||||
this.send('note', note);
|
||||
}
|
||||
|
||||
/*
|
||||
* ミュートとブロックされてるを処理する
|
||||
*/
|
||||
protected override isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
|
||||
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
|
||||
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return true;
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わる
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
|
||||
// 流れてきたNoteがブロックされているユーザーが関わる
|
||||
if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true;
|
||||
|
||||
// 流れてきたNoteがリノートをミュートしてるユーザが行ったもの
|
||||
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
|
||||
|
||||
// このソケットで見ているチャンネルがミュートされていたとしても、チャンネルを直接見ている以上は流すようにしたい
|
||||
// ただし、他のミュートしているチャンネルは流さないようにもしたい
|
||||
// ノート自体のチャンネルIDはonNoteでチェックしているので、ここではリノートのチャンネルIDをチェックする
|
||||
if (
|
||||
(note.renote) &&
|
||||
(note.renote.channelId !== this.channelId) &&
|
||||
(note.renote.channelId && this.mutingChannels.has(note.renote.channelId))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
// Unsubscribe events
|
||||
|
||||
@@ -44,7 +44,10 @@ class HomeTimelineChannel extends Channel {
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
if (note.channelId) {
|
||||
if (!this.followingChannels.has(note.channelId)) return;
|
||||
// そのチャンネルをフォローしていない
|
||||
if (!this.followingChannels.has(note.channelId)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// その投稿のユーザーをフォローしていなかったら弾く
|
||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||
|
||||
@@ -53,16 +53,25 @@ class HybridTimelineChannel extends Channel {
|
||||
|
||||
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
|
||||
|
||||
// チャンネルの投稿ではなく、自分自身の投稿 または
|
||||
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
|
||||
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
|
||||
// フォローしているチャンネルの投稿 の場合だけ
|
||||
if (!(
|
||||
(note.channelId == null && isMe) ||
|
||||
(note.channelId == null && Object.hasOwn(this.following, note.userId)) ||
|
||||
(note.channelId == null && (note.user.host == null && note.visibility === 'public')) ||
|
||||
(note.channelId != null && this.followingChannels.has(note.channelId))
|
||||
)) return;
|
||||
if (!note.channelId) {
|
||||
// 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする)
|
||||
// - 自分自身の投稿
|
||||
// - その投稿のユーザーをフォローしている
|
||||
// - 全体公開のローカルの投稿
|
||||
if (!(
|
||||
isMe ||
|
||||
Object.hasOwn(this.following, note.userId) ||
|
||||
(note.user.host == null && note.visibility === 'public')
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする)
|
||||
// - フォローしているチャンネルの投稿
|
||||
if (!this.followingChannels.has(note.channelId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
|
||||
|
||||
@@ -69,6 +69,9 @@ describe('アンテナ', () => {
|
||||
let userMutingAlice: User;
|
||||
let userMutedByAlice: User;
|
||||
|
||||
let testChannel: misskey.entities.Channel;
|
||||
let testMutedChannel: misskey.entities.Channel;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await signup({ username: 'root' });
|
||||
alice = await signup({ username: 'alice' });
|
||||
@@ -120,6 +123,10 @@ describe('アンテナ', () => {
|
||||
userMutedByAlice = await signup({ username: 'userMutedByAlice' });
|
||||
await post(userMutedByAlice, { text: 'test' });
|
||||
await api('mute/create', { userId: userMutedByAlice.id }, alice);
|
||||
|
||||
testChannel = (await api('channels/create', { name: 'test' }, root)).body;
|
||||
testMutedChannel = (await api('channels/create', { name: 'test-muted' }, root)).body;
|
||||
await api('channels/mute/create', { channelId: testMutedChannel.id }, alice);
|
||||
}, 1000 * 60 * 10);
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -605,6 +612,20 @@ describe('アンテナ', () => {
|
||||
{ note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'チャンネルノートも含む',
|
||||
parameters: () => ({ src: 'all' }),
|
||||
posts: [
|
||||
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}`, channelId: testChannel.id }), included: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ミュートしてるチャンネルは含まない',
|
||||
parameters: () => ({ src: 'all' }),
|
||||
posts: [
|
||||
{ note: (): Promise<Note> => post(bob, { text: `test ${keyword}`, channelId: testMutedChannel.id }) },
|
||||
],
|
||||
},
|
||||
])('が取得できること($label)', async ({ parameters, posts }) => {
|
||||
const antenna = await successfulApiCall({
|
||||
endpoint: 'antennas/create',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
module.exports = async () => {
|
||||
// DBはUTC(っぽい)ので、テスト側も合わせておく
|
||||
process.env.TZ = 'UTC';
|
||||
process.env.NODE_ENV = 'test';
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import {
|
||||
type ChannelFollowingsRepository,
|
||||
ChannelsRepository,
|
||||
DriveFilesRepository,
|
||||
MiChannel,
|
||||
MiChannelFollowing,
|
||||
MiDriveFile,
|
||||
MiUser,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ChannelFollowingService } from "@/core/ChannelFollowingService.js";
|
||||
import { MiLocalUser } from "@/models/User.js";
|
||||
|
||||
describe('ChannelFollowingService', () => {
|
||||
let app: TestingModule;
|
||||
let service: ChannelFollowingService;
|
||||
let channelsRepository: ChannelsRepository;
|
||||
let channelFollowingsRepository: ChannelFollowingsRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let userProfilesRepository: UserProfilesRepository;
|
||||
let driveFilesRepository: DriveFilesRepository;
|
||||
let idService: IdService;
|
||||
|
||||
let alice: MiLocalUser;
|
||||
let bob: MiLocalUser;
|
||||
let channel1: MiChannel;
|
||||
let channel2: MiChannel;
|
||||
let channel3: MiChannel;
|
||||
let driveFile1: MiDriveFile;
|
||||
let driveFile2: MiDriveFile;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
username: 'username',
|
||||
usernameLower: 'username',
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await userProfilesRepository.insert({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createChannel(data: Partial<MiChannel> = {}) {
|
||||
return await channelsRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function createChannelFollowing(data: Partial<MiChannelFollowing> = {}) {
|
||||
return await channelFollowingsRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelFollowingsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function fetchChannelFollowing() {
|
||||
return await channelFollowingsRepository.findBy({});
|
||||
}
|
||||
|
||||
async function createDriveFile(data: Partial<MiDriveFile> = {}) {
|
||||
return await driveFilesRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
md5: 'md5',
|
||||
name: 'name',
|
||||
size: 0,
|
||||
type: 'type',
|
||||
storedInternal: false,
|
||||
url: 'url',
|
||||
...data,
|
||||
})
|
||||
.then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
CoreModule,
|
||||
],
|
||||
providers: [
|
||||
GlobalEventService,
|
||||
IdService,
|
||||
ChannelFollowingService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
service = app.get<ChannelFollowingService>(ChannelFollowingService);
|
||||
idService = app.get<IdService>(IdService);
|
||||
channelsRepository = app.get<ChannelsRepository>(DI.channelsRepository);
|
||||
channelFollowingsRepository = app.get<ChannelFollowingsRepository>(DI.channelFollowingsRepository);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
|
||||
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
alice = { ...await createUser({ username: 'alice' }), host: null, uri: null };
|
||||
bob = { ...await createUser({ username: 'bob' }), host: null, uri: null };
|
||||
driveFile1 = await createDriveFile();
|
||||
driveFile2 = await createDriveFile();
|
||||
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });
|
||||
channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id });
|
||||
channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await channelFollowingsRepository.deleteAll();
|
||||
await channelsRepository.deleteAll();
|
||||
await userProfilesRepository.deleteAll();
|
||||
await usersRepository.deleteAll();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
test('default', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[0].userId).toBe(alice.id);
|
||||
expect(followings[0].user).toBeFalsy();
|
||||
expect(followings[0].bannerId).toBe(driveFile1.id);
|
||||
expect(followings[0].banner).toBeFalsy();
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
expect(followings[1].userId).toBe(alice.id);
|
||||
expect(followings[1].user).toBeFalsy();
|
||||
expect(followings[1].bannerId).toBe(driveFile2.id);
|
||||
expect(followings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('idOnly', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id }, { idOnly: true });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
});
|
||||
|
||||
test('joinUser', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id }, { joinUser: true });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[0].user).toEqual(alice);
|
||||
expect(followings[0].banner).toBeFalsy();
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
expect(followings[1].user).toEqual(alice);
|
||||
expect(followings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('joinBannerFile', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id });
|
||||
await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id });
|
||||
|
||||
const followings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true });
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
expect(followings[0].id).toBe(channel1.id);
|
||||
expect(followings[0].user).toBeFalsy();
|
||||
expect(followings[0].banner).toEqual(driveFile1);
|
||||
expect(followings[1].id).toBe(channel2.id);
|
||||
expect(followings[1].user).toBeFalsy();
|
||||
expect(followings[1].banner).toEqual(driveFile2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('follow', () => {
|
||||
test('default', async () => {
|
||||
await service.follow(alice, channel1);
|
||||
|
||||
const followings = await fetchChannelFollowing();
|
||||
|
||||
expect(followings).toHaveLength(1);
|
||||
expect(followings[0].followeeId).toBe(channel1.id);
|
||||
expect(followings[0].followerId).toBe(alice.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unfollow', () => {
|
||||
test('default', async () => {
|
||||
await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id });
|
||||
|
||||
await service.unfollow(alice, channel1);
|
||||
|
||||
const followings = await fetchChannelFollowing();
|
||||
|
||||
expect(followings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import {
|
||||
ChannelMutingRepository,
|
||||
ChannelsRepository,
|
||||
DriveFilesRepository,
|
||||
MiChannel,
|
||||
MiChannelMuting,
|
||||
MiDriveFile,
|
||||
MiUser,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
describe('ChannelMutingService', () => {
|
||||
let app: TestingModule;
|
||||
let service: ChannelMutingService;
|
||||
let channelsRepository: ChannelsRepository;
|
||||
let channelMutingRepository: ChannelMutingRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let userProfilesRepository: UserProfilesRepository;
|
||||
let driveFilesRepository: DriveFilesRepository;
|
||||
let idService: IdService;
|
||||
|
||||
let alice: MiUser;
|
||||
let bob: MiUser;
|
||||
let channel1: MiChannel;
|
||||
let channel2: MiChannel;
|
||||
let channel3: MiChannel;
|
||||
let driveFile1: MiDriveFile;
|
||||
let driveFile2: MiDriveFile;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
username: 'username',
|
||||
usernameLower: 'username',
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await userProfilesRepository.insert({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createChannel(data: Partial<MiChannel> = {}) {
|
||||
return await channelsRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function createChannelMuting(data: Partial<MiChannelMuting> = {}) {
|
||||
return await channelMutingRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => channelMutingRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function fetchChannelMuting() {
|
||||
return await channelMutingRepository.findBy({});
|
||||
}
|
||||
|
||||
async function createDriveFile(data: Partial<MiDriveFile> = {}) {
|
||||
return await driveFilesRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
md5: 'md5',
|
||||
name: 'name',
|
||||
size: 0,
|
||||
type: 'type',
|
||||
storedInternal: false,
|
||||
url: 'url',
|
||||
...data,
|
||||
})
|
||||
.then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
CoreModule,
|
||||
],
|
||||
providers: [
|
||||
GlobalEventService,
|
||||
IdService,
|
||||
ChannelMutingService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
service = app.get<ChannelMutingService>(ChannelMutingService);
|
||||
idService = app.get<IdService>(IdService);
|
||||
channelsRepository = app.get<ChannelsRepository>(DI.channelsRepository);
|
||||
channelMutingRepository = app.get<ChannelMutingRepository>(DI.channelMutingRepository);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
userProfilesRepository = app.get<UserProfilesRepository>(DI.userProfilesRepository);
|
||||
driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
alice = await createUser({ username: 'alice' });
|
||||
bob = await createUser({ username: 'bob' });
|
||||
driveFile1 = await createDriveFile();
|
||||
driveFile2 = await createDriveFile();
|
||||
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });
|
||||
channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id });
|
||||
channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await channelMutingRepository.deleteAll();
|
||||
await channelsRepository.deleteAll();
|
||||
await userProfilesRepository.deleteAll();
|
||||
await usersRepository.deleteAll();
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
test('default', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[0].userId).toBe(alice.id);
|
||||
expect(mutings[0].user).toBeFalsy();
|
||||
expect(mutings[0].bannerId).toBe(driveFile1.id);
|
||||
expect(mutings[0].banner).toBeFalsy();
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
expect(mutings[1].userId).toBe(alice.id);
|
||||
expect(mutings[1].user).toBeFalsy();
|
||||
expect(mutings[1].bannerId).toBe(driveFile2.id);
|
||||
expect(mutings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('withoutExpires', async () => {
|
||||
const now = new Date();
|
||||
const past = new Date(now);
|
||||
const future = new Date(now);
|
||||
past.setMinutes(past.getMinutes() - 1);
|
||||
future.setMinutes(future.getMinutes() + 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel2.id);
|
||||
expect(mutings[1].id).toBe(channel3.id);
|
||||
});
|
||||
|
||||
test('idOnly', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
});
|
||||
|
||||
test('withoutExpires-idOnly', async () => {
|
||||
const now = new Date();
|
||||
const past = new Date(now);
|
||||
const future = new Date(now);
|
||||
past.setMinutes(past.getMinutes() - 1);
|
||||
future.setMinutes(future.getMinutes() + 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel2.id);
|
||||
expect(mutings[1].id).toBe(channel3.id);
|
||||
});
|
||||
|
||||
test('joinUser', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { joinUser: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[0].user).toEqual(alice);
|
||||
expect(mutings[0].banner).toBeFalsy();
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
expect(mutings[1].user).toEqual(alice);
|
||||
expect(mutings[1].banner).toBeFalsy();
|
||||
});
|
||||
|
||||
test('joinBannerFile', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id });
|
||||
|
||||
const mutings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true });
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].id).toBe(channel1.id);
|
||||
expect(mutings[0].user).toBeFalsy();
|
||||
expect(mutings[0].banner).toEqual(driveFile1);
|
||||
expect(mutings[1].id).toBe(channel2.id);
|
||||
expect(mutings[1].user).toBeFalsy();
|
||||
expect(mutings[1].banner).toEqual(driveFile2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExpiredMutings', () => {
|
||||
test('default', async () => {
|
||||
const now = new Date();
|
||||
const future = new Date(now);
|
||||
const past = new Date(now);
|
||||
future.setMinutes(now.getMinutes() + 1);
|
||||
past.setMinutes(now.getMinutes() - 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past });
|
||||
|
||||
const mutings = await service.findExpiredMutings();
|
||||
|
||||
expect(mutings).toHaveLength(2);
|
||||
expect(mutings[0].channelId).toBe(channel1.id);
|
||||
expect(mutings[1].channelId).toBe(channel3.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMuted', () => {
|
||||
test('isMuted: true', async () => {
|
||||
// キャッシュを読むのでServiceの機能を使って登録し、キャッシュを作成する
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id });
|
||||
|
||||
await setTimeout(500);
|
||||
|
||||
const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('isMuted: false', async () => {
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id });
|
||||
|
||||
await setTimeout(500);
|
||||
|
||||
const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mute', () => {
|
||||
test('default', async () => {
|
||||
await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
const muting = await fetchChannelMuting();
|
||||
expect(muting).toHaveLength(1);
|
||||
expect(muting[0].channelId).toBe(channel1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmute', () => {
|
||||
test('default', async () => {
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id });
|
||||
|
||||
let muting = await fetchChannelMuting();
|
||||
expect(muting).toHaveLength(1);
|
||||
expect(muting[0].channelId).toBe(channel1.id);
|
||||
|
||||
await service.unmute({ requestUserId: alice.id, targetChannelId: channel1.id });
|
||||
|
||||
muting = await fetchChannelMuting();
|
||||
expect(muting).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eraseExpiredMutings', () => {
|
||||
test('default', async () => {
|
||||
const now = new Date();
|
||||
const future = new Date(now);
|
||||
const past = new Date(now);
|
||||
future.setMinutes(now.getMinutes() + 1);
|
||||
past.setMinutes(now.getMinutes() - 1);
|
||||
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past });
|
||||
await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future });
|
||||
await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past });
|
||||
|
||||
await service.eraseExpiredMutings();
|
||||
|
||||
const mutings = await fetchChannelMuting();
|
||||
expect(mutings).toHaveLength(1);
|
||||
expect(mutings[0].channelId).toBe(channel2.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ describe('NoteCreateService', () => {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteChannelId: null,
|
||||
};
|
||||
|
||||
const poll: IPoll = {
|
||||
|
||||
@@ -44,6 +44,7 @@ const base: MiNote = {
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteChannelId: null,
|
||||
};
|
||||
|
||||
describe('misc:is-renote', () => {
|
||||
|
||||
@@ -316,8 +316,12 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
||||
: new URL(path, new URL('resources/', import.meta.url));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob ??
|
||||
new File([new Uint8Array(await readFile(absPath))], basename(absPath.toString())));
|
||||
formData.append(
|
||||
'file',
|
||||
blob ?? new Blob([new Uint8Array(await readFile(absPath))]),
|
||||
basename(absPath.toString()),
|
||||
);
|
||||
|
||||
formData.append('force', 'true');
|
||||
if (name) {
|
||||
formData.append('name', name);
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/node": "22.18.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"rollup": "4.52.3",
|
||||
"@types/node": "24.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"rollup": "4.52.4",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"estree-walker": "3.0.3",
|
||||
"magic-string": "0.30.19",
|
||||
"vite": "7.1.7"
|
||||
"vite": "7.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.52.3",
|
||||
"rollup": "4.52.4",
|
||||
"sass": "1.93.2",
|
||||
"shiki": "3.13.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"uuid": "13.0.0",
|
||||
"vite": "7.1.7",
|
||||
"vite": "7.1.9",
|
||||
"vue": "3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43,12 +43,12 @@
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"acorn": "8.15.0",
|
||||
@@ -56,17 +56,17 @@
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.5.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "20.0.2",
|
||||
"happy-dom": "20.0.7",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.11.3",
|
||||
"msw": "2.11.5",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"start-server-and-test": "2.1.2",
|
||||
"tsx": "4.20.6",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.1.0",
|
||||
"vue-component-type-helpers": "3.1.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.0"
|
||||
"vue-tsc": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ export const instanceName = (siteName === 'Misskey' || siteName == null) ? host
|
||||
export const ui = localStorage.getItem('ui');
|
||||
export const debug = localStorage.getItem('debug') === 'true';
|
||||
export const isSafeMode = localStorage.getItem('isSafeMode') === 'true';
|
||||
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion)').matches;
|
||||
|
||||
@@ -39,13 +39,18 @@ for (let i = 0; i < emojilist.length; i++) {
|
||||
|
||||
export const emojiCharByCategory = _charGroupByCategory;
|
||||
|
||||
export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
|
||||
export function getUnicodeEmojiOrNull(char: string): UnicodeEmojiDef | null {
|
||||
// Colorize it because emojilist.json assumes that
|
||||
return unicodeEmojisMap.get(colorizeEmoji(char))
|
||||
// カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする
|
||||
?? unicodeEmojisMap.get(char)
|
||||
// それでも見つからない場合はそのまま返す(絵文字情報がjsonに無い場合、このフォールバックが無いとレンダリングに失敗する)
|
||||
?? char;
|
||||
// それでも見つからない場合はnullを返す
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
|
||||
// 絵文字が見つからない場合はそのまま返す(絵文字情報がjsonに無い場合、このフォールバックが無いとレンダリングに失敗する)
|
||||
return getUnicodeEmojiOrNull(char) ?? char;
|
||||
}
|
||||
|
||||
export function isSupportedEmoji(char: string): boolean {
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.18.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@types/node": "24.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"esbuild": "0.25.10",
|
||||
"eslint-plugin-vue": "10.5.0",
|
||||
"nodemon": "3.1.10",
|
||||
|
||||
@@ -33,6 +33,7 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
|
||||
description: null,
|
||||
userId: null,
|
||||
bannerUrl,
|
||||
bannerId: null,
|
||||
pinnedNoteIds: [],
|
||||
color: '#000',
|
||||
isArchived: false,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -24,7 +24,7 @@
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@sentry/vue": "10.17.0",
|
||||
"@sentry/vue": "10.20.0",
|
||||
"@syuilo/aiscript": "1.1.2",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
@@ -36,7 +36,7 @@
|
||||
"broadcast-channel": "7.1.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"chart.js": "4.5.0",
|
||||
"chart.js": "4.5.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "3.0.0",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
@@ -48,6 +48,7 @@
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"execa": "9.6.0",
|
||||
"exifreader": "4.32.0",
|
||||
"frontend-shared": "workspace:*",
|
||||
"icons-subsetter": "workspace:*",
|
||||
"idb-keyval": "6.2.2",
|
||||
@@ -57,7 +58,7 @@
|
||||
"json5": "2.2.3",
|
||||
"magic-string": "0.30.19",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.21.1",
|
||||
"mediabunny": "1.23.0",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
@@ -66,7 +67,7 @@
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"rollup": "4.52.3",
|
||||
"rollup": "4.52.4",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.93.2",
|
||||
"shiki": "3.13.0",
|
||||
@@ -79,7 +80,7 @@
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "7.1.7",
|
||||
"vite": "7.1.9",
|
||||
"vue": "3.5.22",
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
@@ -109,30 +110,30 @@
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"acorn": "8.15.0",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.3.0",
|
||||
"cypress": "15.4.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.5.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "20.0.2",
|
||||
"happy-dom": "20.0.7",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"minimatch": "10.0.3",
|
||||
"msw": "2.11.3",
|
||||
"msw-storybook-addon": "2.0.5",
|
||||
"msw": "2.11.5",
|
||||
"msw-storybook-addon": "2.0.6",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"react": "19.2.0",
|
||||
@@ -142,11 +143,12 @@
|
||||
"storybook": "9.1.10",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.20.6",
|
||||
"vite-plugin-glsl": "1.5.4",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "3.2.4",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.1.0",
|
||||
"vue-component-type-helpers": "3.1.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.0"
|
||||
"vue-tsc": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,13 +211,13 @@ export async function switchAccount(host: string, id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAccountMenu(opts: {
|
||||
export async function getAccountMenu(opts: {
|
||||
includeCurrentAccount?: boolean;
|
||||
withExtraOperation: boolean;
|
||||
active?: Misskey.entities.User['id'];
|
||||
onChoose?: (account: Misskey.entities.MeDetailed) => void;
|
||||
}, ev: MouseEvent) {
|
||||
if (!$i) return;
|
||||
}) {
|
||||
if ($i == null) throw new Error('No current account');
|
||||
const me = $i;
|
||||
|
||||
const callback = opts.onChoose;
|
||||
@@ -338,9 +338,7 @@ export async function openAccountMenu(opts: {
|
||||
menuItems.push(...accountItems);
|
||||
}
|
||||
|
||||
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
|
||||
|
||||
@@ -69,9 +69,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
if (lastVersion !== version) {
|
||||
miLocalStorage.setItem('lastVersion', version);
|
||||
|
||||
// テーマリビルドするため
|
||||
miLocalStorage.removeItem('theme');
|
||||
|
||||
try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
|
||||
if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
|
||||
isClientUpdated = true;
|
||||
@@ -176,7 +173,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
})();
|
||||
|
||||
applyTheme(theme);
|
||||
}, { immediate: isSafeMode || miLocalStorage.getItem('theme') == null });
|
||||
}, { immediate: true });
|
||||
|
||||
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
|
||||
|
||||
@@ -195,14 +192,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
applyTheme(theme ?? defaultLightTheme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSafeMode) {
|
||||
if (prefer.s.darkTheme && store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);
|
||||
} else if (prefer.s.lightTheme && !store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme);
|
||||
}
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
// TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア
|
||||
|
||||
@@ -303,13 +303,6 @@ export async function mainBoot() {
|
||||
});
|
||||
}
|
||||
|
||||
if ('Notification' in window) {
|
||||
// 許可を得ていなかったらリクエスト
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
if (store.s.realtimeMode) {
|
||||
const stream = useStream();
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
vec3 mod289(vec3 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec2 mod289(vec2 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
|
||||
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
|
||||
vec2 i1;
|
||||
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
|
||||
i = mod289(i);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||
|
||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
|
||||
m = m*m;
|
||||
m = m*m;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_spread;
|
||||
uniform float u_speed;
|
||||
uniform float u_warp;
|
||||
uniform float u_focus;
|
||||
uniform float u_itensity;
|
||||
out vec4 out_color;
|
||||
|
||||
float circle(in vec2 _pos, in vec2 _origin, in float _radius) {
|
||||
float SPREAD = 0.7 * u_spread;
|
||||
float SPEED = 0.00055 * u_speed;
|
||||
float WARP = 1.5 * u_warp;
|
||||
float FOCUS = 1.15 * u_focus;
|
||||
|
||||
vec2 dist = _pos - _origin;
|
||||
|
||||
float distortion = snoise(vec2(
|
||||
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
|
||||
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
|
||||
)) * 0.5 + 0.5;
|
||||
|
||||
float feather = 0.01 + SPREAD * pow(distortion, FOCUS);
|
||||
|
||||
return 1.0 - smoothstep(
|
||||
_radius - (_radius * feather),
|
||||
_radius + (_radius * feather),
|
||||
dot( dist, dist ) * 4.0
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 green = vec3(1.0) - vec3(153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0);
|
||||
vec3 purple = vec3(1.0) - vec3(195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0);
|
||||
vec3 orange = vec3(1.0) - vec3(255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0);
|
||||
|
||||
float ratio = u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv = vec2(in_uv.x, in_uv.y / ratio) * 0.5 + 0.5;
|
||||
|
||||
vec3 color = vec3(0.0);
|
||||
|
||||
float greenMix = snoise(in_uv * 1.31 + u_time * 0.8 * 0.00017) * 0.5 + 0.5;
|
||||
float purpleMix = snoise(in_uv * 1.26 + u_time * 0.8 * -0.0001) * 0.5 + 0.5;
|
||||
float orangeMix = snoise(in_uv * 1.34 + u_time * 0.8 * 0.00015) * 0.5 + 0.5;
|
||||
|
||||
float alphaOne = 0.35 + 0.65 * pow(snoise(vec2(u_time * 0.00012, uv.x)) * 0.5 + 0.5, 1.2);
|
||||
float alphaTwo = 0.35 + 0.65 * pow(snoise(vec2((u_time + 1561.0) * 0.00014, uv.x )) * 0.5 + 0.5, 1.2);
|
||||
float alphaThree = 0.35 + 0.65 * pow(snoise(vec2((u_time + 3917.0) * 0.00013, uv.x )) * 0.5 + 0.5, 1.2);
|
||||
|
||||
color += vec3(circle(uv, vec2(0.22 + sin(u_time * 0.000201) * 0.06, 0.80 + cos(u_time * 0.000151) * 0.06), 0.15)) * alphaOne * (purple * purpleMix + orange * orangeMix);
|
||||
color += vec3(circle(uv, vec2(0.90 + cos(u_time * 0.000166) * 0.06, 0.42 + sin(u_time * 0.000138) * 0.06), 0.18)) * alphaTwo * (green * greenMix + purple * purpleMix);
|
||||
color += vec3(circle(uv, vec2(0.19 + sin(u_time * 0.000112) * 0.06, 0.25 + sin(u_time * 0.000192) * 0.06), 0.09)) * alphaThree * (orange * orangeMix);
|
||||
|
||||
color *= u_itensity + 1.0 * pow(snoise(vec2(in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009)) * 0.5 + 0.5, 2.0);
|
||||
|
||||
vec3 inverted = vec3(1.0) - color;
|
||||
out_color = vec4(color, max(max(color.x, color.y), color.z));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
in vec2 position;
|
||||
uniform vec2 u_scale;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
in_uv = position / u_scale;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import vertexShaderSource from './MkAnimBg.vertex.glsl';
|
||||
import fragmentShaderSource from './MkAnimBg.fragment.glsl';
|
||||
import { initShaderProgram } from '@/utility/webgl.js';
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
@@ -42,126 +44,7 @@ onMounted(() => {
|
||||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, `#version 300 es
|
||||
in vec2 position;
|
||||
uniform vec2 u_scale;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
in_uv = position / u_scale;
|
||||
}
|
||||
`, `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
vec3 mod289(vec3 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec2 mod289(vec2 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439);
|
||||
|
||||
vec2 i = floor(v + dot(v, C.yy) );
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
|
||||
vec2 i1;
|
||||
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
|
||||
i = mod289(i);
|
||||
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0 ));
|
||||
|
||||
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
||||
m = m*m ;
|
||||
m = m*m ;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
|
||||
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_spread;
|
||||
uniform float u_speed;
|
||||
uniform float u_warp;
|
||||
uniform float u_focus;
|
||||
uniform float u_itensity;
|
||||
out vec4 out_color;
|
||||
|
||||
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
|
||||
float SPREAD = 0.7 * u_spread;
|
||||
float SPEED = 0.00055 * u_speed;
|
||||
float WARP = 1.5 * u_warp;
|
||||
float FOCUS = 1.15 * u_focus;
|
||||
|
||||
vec2 dist = _pos - _origin;
|
||||
|
||||
float distortion = snoise( vec2(
|
||||
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
|
||||
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
|
||||
) ) * 0.5 + 0.5;
|
||||
|
||||
float feather = 0.01 + SPREAD * pow( distortion, FOCUS );
|
||||
|
||||
return 1.0 - smoothstep(
|
||||
_radius - ( _radius * feather ),
|
||||
_radius + ( _radius * feather ),
|
||||
dot( dist, dist ) * 4.0
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 );
|
||||
vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 );
|
||||
vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 );
|
||||
|
||||
float ratio = u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv = vec2( in_uv.x, in_uv.y / ratio ) * 0.5 + 0.5;
|
||||
|
||||
vec3 color = vec3( 0.0 );
|
||||
|
||||
float greenMix = snoise( in_uv * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
|
||||
float purpleMix = snoise( in_uv * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
|
||||
float orangeMix = snoise( in_uv * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
|
||||
|
||||
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
|
||||
color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix );
|
||||
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
|
||||
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
|
||||
|
||||
color *= u_itensity + 1.0 * pow( snoise( vec2( in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
|
||||
|
||||
vec3 inverted = vec3( 1.0 ) - color;
|
||||
out_color = vec4(color, max(max(color.x, color.y), color.z));
|
||||
}
|
||||
`);
|
||||
const shaderProgram = initShaderProgram(gl, vertexShaderSource, fragmentShaderSource);
|
||||
if (shaderProgram == null) return;
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
@@ -27,6 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="$i != null && $i.id === channel.userId" style="color: var(--MI_THEME-warn)">
|
||||
<i class="ti ti-user-star ti-fw"></i>
|
||||
<span style="margin-left: 4px;">{{ i18n.ts.youAreAdmin }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article v-if="channel.description">
|
||||
@@ -48,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
@drop.prevent.stop="onDrop"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
|
||||
<div :class="$style.tipContainer">
|
||||
<MkTip k="drive"><div v-html="i18n.ts.driveAboutTip"></div></MkTip>
|
||||
</div>
|
||||
|
||||
<div :class="$style.folders">
|
||||
<XFolder
|
||||
@@ -802,6 +804,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.tipContainer:not(:empty) {
|
||||
padding: 16px 32px;
|
||||
}
|
||||
|
||||
.folders,
|
||||
.files {
|
||||
display: grid;
|
||||
@@ -811,6 +817,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@container (max-width: 600px) {
|
||||
.tipContainer:not(:empty) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.folders,
|
||||
.files {
|
||||
padding: 16px;
|
||||
|
||||
@@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
|
||||
<div
|
||||
:class="$style.embedCodeGenPreviewRoot"
|
||||
>
|
||||
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
|
||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
@@ -91,20 +89,18 @@ import { url } from '@@/js/config.js';
|
||||
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
@@ -314,10 +310,19 @@ onUnmounted(() => {
|
||||
|
||||
.embedCodeGenPreviewRoot {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
cursor: not-allowed;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewWrapper {
|
||||
|
||||
@@ -539,46 +539,44 @@ defineExpose({
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 5;
|
||||
}
|
||||
|
||||
&.w2 {
|
||||
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 6;
|
||||
}
|
||||
|
||||
&.w3 {
|
||||
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 7;
|
||||
}
|
||||
|
||||
&.w4 {
|
||||
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 8;
|
||||
}
|
||||
|
||||
&.w5 {
|
||||
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 9;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
|
||||
--rows: 4;
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--rows: 6;
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--rows: 8;
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
|
||||
--rows: 10;
|
||||
}
|
||||
|
||||
width: calc((var(--eachSize) * var(--columns)) + (#{$pad} * 2));
|
||||
height: calc((var(--eachSize) * var(--rows)) + (#{$pad} * 2));
|
||||
|
||||
&.asDrawer {
|
||||
width: 100% !important;
|
||||
|
||||
@@ -593,7 +591,7 @@ defineExpose({
|
||||
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
font-size: 30px;
|
||||
|
||||
> .config {
|
||||
@@ -635,7 +633,7 @@ defineExpose({
|
||||
::v-deep(section) {
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
font-size: 30px;
|
||||
|
||||
> .item {
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<MkFolder :defaultOpen="true" :canPage="false">
|
||||
<template #label>{{ fx.name }}</template>
|
||||
<template #label>{{ fx.uiDefinition.name }}</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
|
||||
<MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
@@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
|
||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
|
||||
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
|
||||
const fx = FXS.find((fx) => fx.id === layer.value.fxId);
|
||||
const fx = FXS[layer.value.fxId];
|
||||
if (fx == null) {
|
||||
throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'del'): void;
|
||||
(e: 'swapUp'): void;
|
||||
(e: 'swapDown'): void;
|
||||
(ev: 'del'): void;
|
||||
(ev: 'swapUp'): void;
|
||||
(ev: 'swapDown'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown="onImagePointerdown"></canvas>
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div class="_acrylic" :class="$style.editControls">
|
||||
@@ -64,6 +64,7 @@ import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
image: File;
|
||||
@@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]);
|
||||
|
||||
watch(layers, async () => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(layers);
|
||||
renderer.render(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
function addEffect(ev: MouseEvent) {
|
||||
os.popupMenu(FXS.map((fx) => ({
|
||||
text: fx.name,
|
||||
os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({
|
||||
text: fx.uiDefinition.name,
|
||||
action: () => {
|
||||
layers.push({
|
||||
id: genId(),
|
||||
fxId: fx.id,
|
||||
params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
|
||||
});
|
||||
fxId: id as keyof typeof FXS,
|
||||
params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])),
|
||||
} as ImageEffectorLayer);
|
||||
},
|
||||
})), ev.currentTarget ?? ev.target);
|
||||
}
|
||||
@@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
let renderer: ImageEffector<typeof FXS> | null = null;
|
||||
let renderer: ImageEffector | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -146,31 +147,36 @@ onMounted(async () => {
|
||||
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
try {
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
let w = imageBitmap.width;
|
||||
let h = imageBitmap.height;
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
let w = imageBitmap.width;
|
||||
let h = imageBitmap.height;
|
||||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
canvas: canvasEl.value,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
});
|
||||
|
||||
await renderer.render(layers);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._imageEffector.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
renderer = new ImageEffector({
|
||||
canvas: canvasEl.value,
|
||||
renderWidth: w,
|
||||
renderHeight: h,
|
||||
image: imageBitmap,
|
||||
fxs: FXS,
|
||||
});
|
||||
|
||||
await renderer.setLayers(layers);
|
||||
|
||||
renderer.render();
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
@@ -196,7 +202,7 @@ async function save() {
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
|
||||
renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる
|
||||
canvasEl.value.toBlob((blob) => {
|
||||
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
|
||||
dialog.value?.close();
|
||||
@@ -208,11 +214,10 @@ const enabled = ref(true);
|
||||
watch(enabled, () => {
|
||||
if (renderer != null) {
|
||||
if (enabled.value) {
|
||||
renderer.setLayers(layers);
|
||||
renderer.render(layers);
|
||||
} else {
|
||||
renderer.setLayers([]);
|
||||
renderer.render([]);
|
||||
}
|
||||
renderer.render();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
angle: 0,
|
||||
opacity: 1,
|
||||
color: [1, 1, 1],
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'blur') {
|
||||
@@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
radius: 3,
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'pixelate') {
|
||||
@@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
strength: 0.2,
|
||||
ellipse: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
const scaleY = Math.abs(y - startY);
|
||||
|
||||
const layerIndex = layers.findIndex((l) => l.id === id);
|
||||
const layer = layerIndex !== -1 ? layers[layerIndex] : null;
|
||||
const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null;
|
||||
if (layer != null) {
|
||||
layer.params.offsetX = (x + startX) - 1;
|
||||
layer.params.offsetY = (y + startY) - 1;
|
||||
@@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
@@ -448,6 +465,7 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
margin: 20px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="1000"
|
||||
:height="600"
|
||||
:scroll="false"
|
||||
:withOkButton="true"
|
||||
@close="cancel()"
|
||||
@ok="save()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSelect
|
||||
v-model="params.font" :items="[
|
||||
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
|
||||
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelTop.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelTop.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelBottom.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelBottom.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkInfo>
|
||||
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
|
||||
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
|
||||
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
|
||||
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
|
||||
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
|
||||
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
|
||||
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
|
||||
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
|
||||
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
|
||||
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
|
||||
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
|
||||
</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
|
||||
import ExifReader from 'exifreader';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const props = defineProps<{
|
||||
presetEditMode?: boolean;
|
||||
preset?: ImageFramePreset | null;
|
||||
params?: ImageFrameParams | null;
|
||||
image?: File | null;
|
||||
imageCaption?: string | null;
|
||||
imageFilename?: string | null;
|
||||
}>();
|
||||
|
||||
const preset = deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
};
|
||||
|
||||
const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
|
||||
borderThickness: 0.05,
|
||||
borderRadius: 0,
|
||||
labelTop: {
|
||||
enabled: false,
|
||||
scale: 1.0,
|
||||
padding: 0.2,
|
||||
textBig: '',
|
||||
textSmall: '',
|
||||
centered: false,
|
||||
withQrCode: false,
|
||||
},
|
||||
labelBottom: {
|
||||
enabled: true,
|
||||
scale: 1.0,
|
||||
padding: 0.2,
|
||||
textBig: '{year}/{0month}/{0day}',
|
||||
textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
|
||||
centered: false,
|
||||
withQrCode: true,
|
||||
},
|
||||
bgColor: [1, 1, 1],
|
||||
fgColor: [0, 0, 0],
|
||||
font: 'sans-serif',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', frame: ImageFrameParams): void;
|
||||
(ev: 'presetOk', preset: ImageFramePreset): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
async function cancel() {
|
||||
if (props.presetEditMode) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
const updateThrottled = throttle(50, () => {
|
||||
if (renderer != null) {
|
||||
renderer.render(params);
|
||||
}
|
||||
});
|
||||
|
||||
watch(params, async (newValue, oldValue) => {
|
||||
updateThrottled();
|
||||
}, { deep: true });
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const sampleImage_3_2 = new Image();
|
||||
sampleImage_3_2.src = '/client-assets/sample/3-2.jpg';
|
||||
const sampleImage_3_2_loading = new Promise<void>(resolve => {
|
||||
sampleImage_3_2.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImage_2_3 = new Image();
|
||||
sampleImage_2_3.src = '/client-assets/sample/2-3.jpg';
|
||||
const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
||||
sampleImage_2_3.onload = () => resolve();
|
||||
});
|
||||
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (sampleImageType.value === 'provided') return;
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
});
|
||||
|
||||
let imageFile = props.image;
|
||||
|
||||
async function choiceImage() {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
if (files.length === 0) return;
|
||||
imageFile = files[0];
|
||||
sampleImageType.value = 'provided';
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: ImageFrameRenderer | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
async function initRenderer() {
|
||||
if (canvasEl.value == null) return;
|
||||
|
||||
if (sampleImageType.value === '3_2') {
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage_3_2,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
} else if (sampleImageType.value === '2_3') {
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: sampleImage_2_3,
|
||||
exif: null,
|
||||
caption: 'Example caption',
|
||||
filename: 'example_file_name.jpg',
|
||||
renderAsPreview: true,
|
||||
});
|
||||
} else if (imageFile != null) {
|
||||
imageBitmap = await window.createImageBitmap(imageFile);
|
||||
|
||||
const exif = ExifReader.load(await imageFile.arrayBuffer());
|
||||
|
||||
renderer = new ImageFrameRenderer({
|
||||
canvas: canvasEl.value,
|
||||
image: imageBitmap,
|
||||
exif: exif,
|
||||
caption: props.imageCaption ?? null,
|
||||
filename: props.imageFilename ?? null,
|
||||
renderAsPreview: true,
|
||||
});
|
||||
}
|
||||
|
||||
await renderer!.render(params);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const closeWaiting = os.waiting();
|
||||
|
||||
await nextTick(); // waitingがレンダリングされるまで待つ
|
||||
|
||||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
try {
|
||||
await initRenderer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._imageFrameEditor.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.close();
|
||||
imageBitmap = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (props.presetEditMode) {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
preset.name = name || '';
|
||||
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('presetOk', {
|
||||
...preset,
|
||||
params: deepClone(params),
|
||||
});
|
||||
} else {
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('ok', params);
|
||||
}
|
||||
}
|
||||
|
||||
function getHex(c: [number, number, number]) {
|
||||
return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function getRgb(hex: string | number): [number, number, number] | null {
|
||||
if (
|
||||
typeof hex === 'number' ||
|
||||
typeof hex !== 'string' ||
|
||||
!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
|
||||
if (m == null) return [0, 0, 0];
|
||||
return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.previewControls {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.previewControlsButton {
|
||||
&.active {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.previewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user