diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index e75e32a17a..a028e2685e 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -165,6 +165,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 1ffed00cc7..4be1352bd7 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -177,6 +177,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.config/example.yml b/.config/example.yml index 71427c84bc..d4584215c9 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -259,6 +259,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 3eb4fc2879..6d904e87b9 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -152,6 +152,11 @@ id: 'aidx' # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 1c4bee2095..7d085821b7 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -22,7 +22,7 @@ jobs: uses: pnpm/action-setup@v4.1.0 - name: Setup Node.js - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 52acbfebeb..2e94f433b7 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout head uses: actions/checkout@v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index 50a8c3ab34..090dc70bd5 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -29,7 +29,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 3244a39156..c5a4f77336 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -33,7 +33,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 361bd697e5..4c8b97e785 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -38,7 +38,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.2.0 + - uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -69,13 +69,13 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.2.0 + - uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} @@ -99,7 +99,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.2.0 + - uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index 4c0de376d2..cee4c27ceb 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -20,7 +20,7 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.2.0 + - uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index aa32f2cb3b..9d15e0fcf1 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -26,7 +26,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 6608761c13..e17ce2a889 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -5,7 +5,6 @@ on: branches: - master - develop - - dev/storybook8 # for testing pull_request_target: branches-ignore: # Since pull requests targets master mostly is the "develop" branch. @@ -15,8 +14,12 @@ on: jobs: build: - # chromatic is not likely to be available for fork repositories, so we disable for fork repositories. - if: github.repository == 'misskey-dev/misskey' + # Chromatic is not likely to be available for fork repositories, so we disable for fork repositories. + # Neither Dependabot nor Renovate will change the actual behavior for components. + if: >- + github.repository == 'misskey-dev/misskey' && + startsWith(github.head_ref, 'refs/heads/dependabot/') != true && + startsWith(github.head_ref, 'refs/heads/renovate/') != true runs-on: ubuntu-latest env: @@ -43,7 +46,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js 20.x - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 69652621ca..9c54b3665b 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -62,7 +62,7 @@ jobs: fi done - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -109,7 +109,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml index 93588b54b9..dc29de4d4f 100644 --- a/.github/workflows/test-federation.yml +++ b/.github/workflows/test-federation.yml @@ -44,7 +44,7 @@ jobs: fi done - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 14a754c190..bec5169ed9 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -38,7 +38,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -93,7 +93,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 29b6c6172b..2d1bd20183 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -33,7 +33,7 @@ jobs: uses: pnpm/action-setup@v4.1.0 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 205eae2399..b77550a01f 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -26,7 +26,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index f84efa4821..4023815cb1 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -27,7 +27,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4.3.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/CHANGELOG.md b/CHANGELOG.md index 824386e6bc..d36cb8dd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,86 @@ -## 2025.3.2 +## 2025.4.1 ### General -- セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 +- Feat: bull-boardに代わるジョブキューの管理ツールが実装されました +- Enhance: チャットの新規メッセージをプッシュ通知するように + +### Client +- Feat: チャットウィジェットを追加 +- Feat: デッキにチャットカラムを追加 +- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように +- Enhance: コントロールパネルでジョブキューをクリアできるように +- Enhance: テーマでページヘッダーの色を変更できるように +- Enhance: デザインのブラッシュアップ +- Fix: ログアウトした際に処理が終了しない問題を修正 +- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように +- Fix: フォルダを開いた状態でメニューからアップロードしてもルートフォルダにアップロードされる問題を修正 #15836 +- Fix: タイムラインのスクロール位置を記憶するように修正 +- Fix: ノートの直後のノートを表示する機能で表示が逆順になっていた問題を修正 #15841 +- Fix: アカウントの移行時にアンテナのフィルターのユーザが更新されない問題を修正 #15843 + +### Server +- Enhance: ジョブキューの成功/失敗したジョブも一定数・一定期間保存するようにし、後から問題を調査することを容易に +- Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように + (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38) +- Enhance: ユーザーごとにノートの表示が高速化するように +- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正 +- Fix: 大文字を含むユーザの URL で紹介された場合に 404 エラーを返す問題 #15813 +- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterノードで実行されるように調整( #10897 ) + +## 2025.4.0 + +### General +- Feat: チャット(ダイレクトメッセージ)がリニューアルして復活しました + - 既存のDM機能よりも便利で効率的な実装になっています + - チャットを受け付ける相手を制限可能です + - 誰でも / フォローユーザーのみ / フォロワーのみ / 相互のみ / 受け付けない から選択できます + - 自分からメッセージを送った相手とは上記の設定に関わらずチャット可能です + - チャット機能を開放するかどうかをロールで制御可能です + - ルームを作成して、複数人でのチャットも可能です + - 過去自分が送ったメッセージ・自分に送られたメッセージの検索が可能です + - 参加中のルームをミュートして通知が来ないように設定可能です + - メッセージにはリアクションも可能です + - 現在、リモートユーザーがチャットを受け付ける設定になっているかどうかを取得する術がないため、ローカルユーザー間でのみ利用可能です +- Feat: アカウントの移行時に古いアカウントからあたらしいアカウントにロールをコピーできるようになりました。 + - 管理者がロールの設定でマイグレーション時にコピーするかを指定できるようになります。 +- Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 - Misskeyネイティブでダッシュボードを実装予定です +- Enhance: フロントエンドのエラートラッキングができるように + - `.config/default.yml`中の項目`sentryForFrontend`を適宜設定してください。 + - 外部サービスであるSentryへエラー情報が送信されます。ご利用の地域の法令に従い、適切なプライバシーポリシーを策定の上で運用してください。 +- Enhance: ミュートしているユーザーをユーザー検索の結果から除外するように +- Enhance: アンテナでセンシティブなチャンネルのノートを除外できるように `#14177` +- Fix: 通知のページネーションで2つ以上読み込めなくなることがある問題を修正 ### Client - Feat: 設定の管理が強化されました - - 自動でバックアップされるように + - 内部処理が一新され、安定性とパフォーマンスが向上しました + - 全てのクライアント設定がエクスポート(バックアップ)/インポート対象に含まれるようになりました + - プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました + - 自動で設定データをサーバーにバックアップできるように + - 設定→設定のプロファイル→自動バックアップ で有効にできます + - ログインしたとき、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能) - 任意の設定項目をデバイス間で同期できるように + - 設定項目の「...」メニュー→「デバイス間で同期」 + - 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます + - 任意の設定項目を初期値にリセットできるように + - 設定項目の「...」メニュー→「初期値にリセット」 + - アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように + - 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます + - ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました + - バックアップを有効にしている場合、ログインした後にバックアップから設定データを復元可能です + - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました + - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です + - 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください - Feat: 画面を重ねて表示するオプションを実装(実験的) + - 設定 → その他 → 実験的機能 → Enable stacking router view - Enhance: プラグインの管理が強化されました - インストール/アンインストール/設定の変更時にリロード不要になりました - Enhance: ログアウト時、ブラウザに保存されたWebクライアントのデータを全て消去するように +- Enhance: デッキUIでカラム間のマージンを設定できるように +- Enhance: デッキUIでデッキメニューの位置を設定できるように +- Enhance: デッキUIでナビゲーションバーの位置を設定できるように +- Enhance: アイコンのスクロール追従を無効化してパフォーマンス向上できるように - Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに - Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように - Enhance: テーマ設定画面のデザインを改善 @@ -19,12 +88,23 @@ - 投稿フォームをリセットできるように - 文字数カウントを復活 - Enhance: 2段階認証時のリカバリーコードのファイル名にサーバーURLを含めるように +- Enhance: 全体的なブラッシュアップ +- Enhance 全体的なパフォーマンス向上 +- Enhance: ファイルのアップロードでデフォルトで圧縮するかどうかのオプションが廃止され、アップロード時に圧縮するかどうかを選択するようになりました + - 画像データの貼り付け、ドロップ時は圧縮されるようになりました +- Fix: 読み込み直後にスクロールしようとすると途中で止まる場合があるのを修正 - Fix: テーマ切り替え時に一部の色が変わらない問題を修正 +- Fix: iPadOSでdeck uiをマウスカーソルによってスクロールできない問題を修正 +- NOTE: 構造上クラシックUIを新しいデザインシステムに移行することが困難なため、クラシックUIが削除されました + - デッキUIでカラムを中央寄せにし、メインカラムの左右にウィジェットカラムを配置し、ナビゲーションバーを上部に表示することである程度クラシックUIを再現できます ### Server +- Enhance 全体的なパフォーマンス向上 - Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正 - Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正 - Fix: 連合無しモードでも外部から照会可能だった問題を修正 +- Fix: テスト用WebHookのペイロードの`emojis`パラメータが実際のものと異なる問題を修正 +- Fix: 非ログインでタイムラインのストリームに接続した際、表示にログイン必須のノートが流れる場合がある問題を修正 ## 2025.3.1 diff --git a/chart/files/default.yml b/chart/files/default.yml index 4d17131c25..06f762aafa 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -173,6 +173,11 @@ id: "aidx" # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' #sentryForFrontend: +# vueIntegration: +# tracingOptions: +# trackComponents: true +# browserTracingIntegration: +# replayIntegration: # options: # dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 4966007061..adc37c2414 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -251,7 +251,6 @@ removeAreYouSure: "متأكد من أنك تريد حذف {x}؟" deleteAreYouSure: "متأكد من أنك تريد حذف {x}؟" resetAreYouSure: "هل تريد إعادة التعيين؟" saved: "حُفظ" -messaging: "المحادثة" upload: "ارفع" keepOriginalUploading: "ابق الصورة الأصلية" keepOriginalUploadingDescription: "يحفظ الصور المرفوعة على حالتها الأصلية، وان عطّل ستولد نسخة مخصصة من الصورة." @@ -264,7 +263,6 @@ uploadFromUrlMayTakeTime: "سيستغرق بعض الوقت لاتمام الر explore: "استكشاف" messageRead: "مقروءة" noMoreHistory: "لا يوجد المزيد من التاريخ" -startMessaging: "ابدأ محادثة" nUsersRead: "قرأه {n}" agreeTo: "اوافق على {0}" agree: "أقبل" @@ -436,8 +434,6 @@ retype: "أعد الكتابة" noteOf: "ملاحظات {user}" quoteAttached: "اِقتُبسَ" quoteQuestion: "أتريد تضمينها كاقتباس" -noMessagesYet: "ليس هناك رسائل بعد" -newMessageExists: "لقد تلقيت رسالة جديدة" onlyOneFileCanBeAttached: "يمكنك إرفاق ملف واحد بالرسالة" signinRequired: "رجاءً لِج" invitations: "دعوة" @@ -1013,6 +1009,13 @@ flip: "اقلب" lastNDays: "آخر {n} أيام" surrender: "ألغِ" postForm: "أنشئ ملاحظة" +information: "عن" +_chat: + invitations: "دعوة" + noHistory: "السجل فارغ" + members: "الأعضاء" + home: "الرئيسي" + send: "أرسل" _delivery: stop: "مُعلّق" _initialAccountSetting: @@ -1237,7 +1240,6 @@ _theme: shadow: "الظل" navBg: "خلفية الشريط الجانبي" navFg: "نص الشريط الجانبي" - navHoverFg: "نص الشريط الجانبي (عند التمرير فوقه)" link: "رابط" hashtag: "وسم" mention: "أشر الى" @@ -1312,6 +1314,7 @@ _permissions: "read:gallery": "اعرض المعرض" "write:gallery": "عدّل المعرض" "read:gallery-likes": "يعرض ما أعجبك من مشاركات المعرض" + "write:chat": "اكتب أو احذف رسائل محادثة" _auth: shareAccess: "أتريد التفويض لـ \"{name}\" بالوصول لحسابك؟" shareAccessAsk: "هل تخول لهذا التطبيق الوصول لحسابك؟" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 6aea5a1999..442fed6e21 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -252,7 +252,6 @@ removeAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যা deleteAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যাপারে নিশ্চিত?" resetAreYouSure: "রিসেট করার ব্যাপারে নিশ্চিত?" saved: "সংরক্ষিত হয়েছে" -messaging: "চ্যাট" upload: "আপলোড" keepOriginalUploading: "আসল ছবি রাখুন" keepOriginalUploadingDescription: "ছবিটি আপলোড করার সময় আসল সংস্করণটি রাখুন। অপশনটি বন্ধ থাকলে, আপলোডের সময় ওয়েব প্রকাশনার জন্য ছবি ব্রাউজারে তৈরি করা হবে।" @@ -265,7 +264,6 @@ uploadFromUrlMayTakeTime: "URL হতে আপলোড হতে কিছু explore: "ঘুরে দেখুন" messageRead: "পড়া" noMoreHistory: "আর কোন ইতিহাস নেই" -startMessaging: "চ্যাট শুরু করুন" nUsersRead: "{n} জন পড়েছেন" agreeTo: "{0} এর প্রতি আমি সম্মত" start: "শুরু করুন" @@ -427,8 +425,6 @@ retype: "পুনঃ প্রবেশ" noteOf: "{user} এর নোট" quoteAttached: "উদ্ধৃত" quoteQuestion: "উদ্ধৃতি হিসাবে সংযুক্ত করবেন?" -noMessagesYet: "কোন মেসেজ নেই" -newMessageExists: "নতুন মেসেজ পেয়েছেন" onlyOneFileCanBeAttached: "আপনি মেসেজের সাথে সর্বোচ্চ একটি ফাইল যুক্ত করতে পারবেন" signinRequired: "দয়া করে লগ ইন করুন" invitations: "আমন্ত্রণ" @@ -853,6 +849,13 @@ renotes: "রিনোট" sourceCode: "সোর্স কোড" flip: "উল্টান" postForm: "নোট লিখুন" +information: "আপনার সম্পর্কে" +_chat: + invitations: "আমন্ত্রণ" + noHistory: "কোনো ইতিহাস নেই" + members: "সদস্যবৃন্দ" + home: "মূল পাতা" + send: "পাঠান" _delivery: stop: "স্থগিত করা হয়েছে" _type: @@ -995,7 +998,6 @@ _theme: header: "হেডার" navBg: "সাইডবারের পটভূমি" navFg: "সাইডবারের পাঠ্য" - navHoverFg: "সাইডবারের পাঠ্য (হভার)" navActive: "সাইডবারের পাঠ্য (অ্যাকটিভ)" navIndicator: "সাইডবারের ইনডিকেটর" link: "লিংক" @@ -1018,11 +1020,8 @@ _theme: buttonHoverBg: "বাটনের পটভূমি (হভার)" inputBorder: "ইনপুট ফিল্ডের বর্ডার" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" - wallpaperOverlay: "ওয়ালপেপার ওভারলে" badge: "ব্যাজ" messageBg: "চ্যাটের পটভূমি" - accentDarken: "অ্যাকসেন্ট (গাঢ়)" - accentLighten: "অ্যাকসেন্ট (হাল্কা)" fgHighlighted: "হাইলাইট করা পাঠ্য" _sfx: note: "নোটগুলি" @@ -1085,6 +1084,7 @@ _permissions: "write:gallery": "গ্যালারী সম্পাদনা করুন" "read:gallery-likes": "গ্যালারীর পছন্দগুলি দেখুন" "write:gallery-likes": "গ্যালারীর পছন্দগুলি সম্পাদনা করুন" + "write:chat": "চ্যাটগুলি সম্পাদনা করুন" _auth: shareAccess: "\"{name}\" কে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" shareAccessAsk: "অ্যাপ্লিকেশনটিকে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 44fb48f5f5..129c3d24a5 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "Segur que vols esborrar «{x}»?" resetAreYouSure: "Segur que vols restablir-ho?" areYouSure: "Estàs segur?" saved: "S'ha desat" -messaging: "Xat" upload: "Puja" keepOriginalUploading: "Guarda la imatge original" keepOriginalUploadingDescription: "Guarda la imatge pujada sense modificar. Si està desactivat, es generarà una versió per visualitzar a la web en pujar la imatge." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "La càrrega des de l'enllaç pot trigar un temps" explore: "Explora" messageRead: "Vist" noMoreHistory: "No hi ha res més per veure" -startMessaging: "Comença a xatejar" +startChat: "Comença a xatejar " nUsersRead: "Vist per {n}" agreeTo: "Accepto que {0}" agree: "Hi estic d'acord" @@ -357,7 +356,7 @@ banner: "Bàner" displayOfSensitiveMedia: "Visualització de contingut sensible" whenServerDisconnected: "Quan es perdi la connexió al servidor" disconnectedFromServer: "Desconnectat pel servidor" -reload: "Actualitza" +reload: "Actualitzar" doNothing: "Ignora" reloadConfirm: "Vols recarregar?" watch: "Veure" @@ -425,6 +424,7 @@ antennaExcludeBots: "Exclou els bots" antennaKeywordsDescription: "Separar amb espais per la condició AND o amb salts de línia per la condició OR." notifyAntenna: "Notifica'm les publicacions noves" withFileAntenna: "Només les publicacions amb fitxers" +excludeNotesInSensitiveChannel: "Excloure notes a canals sensibles" enableServiceworker: "Activar les notificacions al navegador" antennaUsersDescription: "Llistar un nom d'usuari per línia" caseSensitive: "Sensible a majúscules i minúscules " @@ -491,8 +491,6 @@ noteOf: "Publicació de: {user}" quoteAttached: "Frase adjunta" quoteQuestion: "Vols annexar-la com a cita?" attachAsFileQuestion: "El text copiat és massa llarg. Vols adjuntar-lo com un fitxer de text?" -noMessagesYet: "Encara no hi ha missatges" -newMessageExists: "Has rebut un nou missatge" onlyOneFileCanBeAttached: "Només pots adjuntar un fitxer a un missatge" signinRequired: "Si us plau, Registra't o inicia la sessió abans de continuar" signinOrContinueOnRemote: "Per continuar necessites moure el teu servidor o registrar-te / iniciar sessió en aquest servidor." @@ -539,7 +537,7 @@ mediaListWithOneImageAppearance: "Altura de la llista de fitxers amb una única limitTo: "Limita a {x}" noFollowRequests: "No tens sol·licituds de seguiment" openImageInNewTab: "Obre imatges a una nova pestanya" -dashboard: "Taulell de control" +dashboard: "Tauler de control" local: "Local" remote: "Remot" total: "Total" @@ -654,7 +652,7 @@ manage: "Administració" plugins: "Extensions" preferencesBackups: "Configuracions de les Còpies de seguretat" deck: "Escriptori" -undeck: "Tanca l'escriptori" +undeck: "Tanca el tauler" useBlurEffectForModal: "Utilitzar l'efecte de difuminació a modals" useFullReactionPicker: "Utilitza el cercador de reaccions d'escala sencera" width: "Amplada" @@ -710,7 +708,7 @@ notificationSetting: "Paràmetres de notificacions" notificationSettingDesc: "Selecciona els tipus de notificacions que es mostraran" useGlobalSetting: "Fer servir la configuració global" useGlobalSettingDesc: "Si s'activa, es farà servir la configuració de notificacions del teu comte. Si no s'activa es poden fer configuracions individuals." -other: "Altre" +other: "Altres" regenerateLoginToken: "Regenerar clau de seguretat d'inici de sessió" regenerateLoginTokenDescription: "Regenera la clau de seguretat que es fa servir internament durant l'inici de sessió. Normalment aquesta acció no és necessària. Si es regenera es tancarà la sessió a tots els dispositius amb una sessió activa." theKeywordWhenSearchingForCustomEmoji: "Cercar un emoji personalitzat " @@ -917,7 +915,7 @@ off: "Desactivar" emailRequiredForSignup: "Demanar correu electrònic per registrar-se " unread: "Sense llegir" filter: "Filtrar" -controlPanel: "Taulell de control" +controlPanel: "Tauler de control" manageAccounts: "Gestionar comptes" makeReactionsPublic: "Reaccions públiques " makeReactionsPublicDescription: "Això fa que totes les teves reaccions siguin visibles públicament " @@ -981,6 +979,7 @@ document: "Documentació" numberOfPageCache: "Nombre de pàgines a la memòria cau" numberOfPageCacheDescription: "Incrementant aquest nombre farà que millori l'experiència de l'usuari, però es farà servir més memòria al dispositiu de l'usuari." logoutConfirm: "Vols sortir?" +logoutWillClearClientData: "En tancar la sessió, la informació del client al navegador s'esborrarà. Per garantir que la informació de configuració es pugui restaurar en tornar a iniciar sessió activa la còpia de seguretat automàtica de la configuració." lastActiveDate: "Fet servir per última vegada" statusbar: "Barra d'estat" pleaseSelect: "Selecciona una opció" @@ -1131,7 +1130,7 @@ pleaseAgreeAllToContinue: "Has d'acceptar tots els camps de dalt per poder conti continue: "Continuar" preservedUsernames: "Noms d'usuaris reservats" preservedUsernamesDescription: "Llistat de noms d'usuaris que no es poden fer servir separats per salts de linia. Aquests noms d'usuaris no estaran disponibles quan es creï un compte d'usuari normal, però els administradors els poden fer servir per crear comptes manualment. Per altre banda els comptes ja creats amb aquests noms d'usuari no es veure'n afectats." -createNoteFromTheFile: "Compon una nota des d'aquest fitxer" +createNoteFromTheFile: "Escriu una nota incloent aquest fitxer" archive: "Arxiu" archived: "Arxivat" unarchive: "Desarxivar" @@ -1334,6 +1333,64 @@ paste: "Pegar" emojiPalette: "Calaix d'emojis" postForm: "Formulari de publicació" textCount: "Nombre de caràcters " +information: "Informació" +chat: "Xat" +migrateOldSettings: "Migrar la configuració anterior" +migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual." +compress: "Comprimir " +right: "Dreta" +bottom: "A baix " +top: "A dalt " +embed: "Incrustar" +settingsMigrating: "Estem migrant la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard, manualment, anant a Preferències → Altres → Migrar configuració antiga)" +readonly: "Només lectura" +goToDeck: "Tornar al tauler" +_chat: + noMessagesYet: "Encara no tens missatges " + newMessage: "Missatge nou" + individualChat: "Xat individual " + individualChat_description: "Pots mantenir converses individuals amb usuaris concrets." + roomChat: "Sala de xat" + roomChat_description: "Pots xatejar amb diverses persones.\nTambé pots xatejar amb usuaris que no poden fer xats privats, si ells accepten." + createRoom: "Crear una sala" + inviteUserToChat: "Invita usuaris per començar a xatejar" + yourRooms: "Sales creades" + joiningRooms: "Sales a les quals participes" + invitations: "Convida" + noInvitations: "No tens cap invitació " + history: "Historial de converses " + noHistory: "No hi ha un registre previ" + noRooms: "No hi ha cap sala" + inviteUser: "Invitar usuaris" + sentInvitations: "Enviar invitacions" + join: "Afegir-se " + ignore: "Ignorar " + leave: "Marxar" + members: "Membres" + searchMessages: "Buscar missatges " + home: "Inici" + send: "Envia" + newline: "Línia nova " + muteThisRoom: "Silenciar aquesta sala" + deleteRoom: "Esborrar la sala" + chatNotAvailableForThisAccountOrServer: "El xat no està disponible per aquest servidor o aquest compte." + chatIsReadOnlyForThisAccountOrServer: "El xat és només de lectura en aquest servidor o compte. No es poden escriure nous missatges ni crear o unir-se a sales de xat." + chatNotAvailableInOtherAccount: "La funció de xat es troba desactivada al compte de l'altre usuari." + cannotChatWithTheUser: "No pots xatejar amb aquest usuari" + cannotChatWithTheUser_description: "El xat està desactivat o l'altra part encara no l'ha obert." + chatWithThisUser: "Xateja amb aquest usuari" + thisUserAllowsChatOnlyFromFollowers: "Aquest usuari només accepta xats d'usuaris que el segueixen." + thisUserAllowsChatOnlyFromFollowing: "Aquest usuari només accepta xats d'usuaris que segueix." + thisUserAllowsChatOnlyFromMutualFollowing: "Aquest usuari només accepta xats d'usuaris que segueixes i et segueixen." + thisUserNotAllowedChatAnyone: "Aquest usuari no accepta xats de ningú." + chatAllowedUsers: "Usuaris que poden xatejar" + chatAllowedUsers_note: "Pots xatejar amb qualsevol usuari a qui hagis enviat un missatge de xat, independentment d'aquesta configuració." + _chatAllowedUsers: + everyone: "Tothom" + followers: "Només els teus seguidors" + following: "Només usuaris als que segueixes" + mutual: "Només seguidors mutus" + none: "Ningú " _emojiPalette: palettes: "Calaixos d'emojis" enableSyncBetweenDevicesForPalettes: "Activa la sincronització dels calaixos d'emojis entre dispositius" @@ -1359,6 +1416,14 @@ _settings: timelineAndNote: "Línia de temps i nota" makeEveryTextElementsSelectable: "Fes que tots els elements del text siguin seleccionables" makeEveryTextElementsSelectable_description: "L'activació pot reduir la usabilitat en determinades ocasions." + useStickyIcons: "Utilitza icones fixes" + showNavbarSubButtons: "Mostrar sub botons a la barra de navegació " + ifOn: "Quan s'activa" + ifOff: "Quan es desactiva" + enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius" + _chat: + showSenderName: "Mostrar el nom del remitent" + sendOnEnter: "Introdueix per enviar" _preferencesProfile: profileName: "Nom del perfil" profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu." @@ -1540,7 +1605,7 @@ _accountMigration: moveTo: "Migrar aquest compte a un altre" moveToLabel: "Compte al qual es vol migrar:" moveCannotBeUndone: "Les migracions dels comptes no es poden desfer." - moveAccountDescription: "Això migrarà la teva compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" + moveAccountDescription: "Això migrarà el teu compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" moveAccountHowTo: "Per fer la migració, primer has de crear un àlies per aquest compte al compte al qual vols migrar.\nDesprés de crear l'àlies, introdueix el compte al qual vols migrar amb el format següent: @nomusuari@servidor.exemple.com" startMigration: "Migrar" migrationConfirm: "Vols migrar aquest compte a {account}? Una vegada comenci la migració no es podrà parar O fer marxa enrere i no podràs tornar a fer servir aquest compte mai més." @@ -1828,6 +1893,8 @@ _role: descriptionOfIsExplorable: "La línia de temps d'aquest rol i la llista d'usuaris seran públics si s'activa." displayOrder: "Posició " descriptionOfDisplayOrder: "Com més gran és el número, més dalt la seva posició a la interfície." + preserveAssignmentOnMoveAccount: "L'estat de l'assignació també es trasllada amb el compte migrat" + preserveAssignmentOnMoveAccount_description: "Si s'activa quan es migra un compte amb aquest rol, el compte migrat també heretarà aquest rol." canEditMembersByModerator: "Permetre que els moderadors editin la llista d'usuaris en aquest rol" descriptionOfCanEditMembersByModerator: "Quan s'activa, els moderadors, així com els administradors, podran afegir i treure usuaris d'aquest rol. Si es troba desactivat, només els administradors poden assignar usuaris." priority: "Prioritat" @@ -1868,6 +1935,7 @@ _role: canImportFollowing: "Autoritza la importació de seguidors" canImportMuting: "Autoritza la importació de silenciats" canImportUserLists: "Autoritza la importació de llistes d'usuaris " + chatAvailability: "Es permet xatejar" _condition: roleAssignedTo: "Assignat a rols manuals" isLocal: "Usuari local" @@ -2059,12 +2127,11 @@ _theme: fg: "Text" focus: "Enfocament" indicator: "Indicador" - panel: "Taulell " + panel: "Tauler" shadow: "Ombra" header: "Capçalera" navBg: "Fons de la barra lateral" navFg: "Text de la barra lateral" - navHoverFg: "Text barra lateral (en passar per sobre)" navActive: "Text barra lateral (actiu)" navIndicator: "Indicador barra lateral" link: "Enllaç" @@ -2087,17 +2154,15 @@ _theme: buttonHoverBg: "Fons botó (en passar-hi per sobre)" inputBorder: "Contorn del cap d'introducció " driveFolderBg: "Fons de la carpeta Disc" - wallpaperOverlay: "Superposició del fons de pantalla " badge: "Insígnia " messageBg: "Fons del xat" - accentDarken: "Accent (fosc)" - accentLighten: "Accent (clar)" fgHighlighted: "Text ressaltat" _sfx: note: "Notes" noteMy: "Nota (per mi)" notification: "Notificacions" reaction: "Quan se selecciona una reacció " + chatMessage: "Missatges del xat" _soundSettings: driveFile: "Fer servir un fitxer d'àudio del disc" driveFileWarn: "Seleccionar un fitxer d'àudio del disc" @@ -2244,6 +2309,8 @@ _permissions: "read:clip-favorite": "Veure clips favorits" "read:federation": "Veure dades de federació" "write:report-abuse": "Informar d'un abús" + "write:chat": "Crear o esborrar missatges de xat" + "read:chat": "Explorar xats" _auth: shareAccessTitle: "Concedeix permisos a l'aplicació" shareAccess: "Vols que {name} pugui accedir al vostre compte?" @@ -2302,6 +2369,7 @@ _widgets: chooseList: "Tria una llista" clicker: "Clicker" birthdayFollowings: "Usuaris que fan l'aniversari avui" + chat: "Xat" _cw: hide: "Amagar" show: "Carregar més" @@ -2492,6 +2560,7 @@ _notification: newNote: "Nota nova" unreadAntennaNote: "Antena {name}" roleAssigned: "Rol assignat " + chatRoomInvitationReceived: "T'han invitat a una sala de xat" emptyPushNotificationMessage: "Les notificacions han sigut actualitzades" achievementEarned: "Aconseguiment desblocat" testNotification: "Notificació de prova" @@ -2520,6 +2589,7 @@ _notification: receiveFollowRequest: "Rebuda una petició de seguiment" followRequestAccepted: "Petició de seguiment acceptada" roleAssigned: "Rol donat" + chatRoomInvitationReceived: "Invitat a la sala de xat" achievementEarned: "Assoliment desbloquejat" exportCompleted: "Exportació completada" login: "Iniciar sessió" @@ -2533,7 +2603,10 @@ _notification: _deck: alwaysShowMainColumn: "Mostrar sempre la columna principal" columnAlign: "Alinea les columnes" - addColumn: "Afig una columna" + columnGap: "Espai entre columnes" + deckMenuPosition: "Posició del menú del tauler" + navbarPosition: "Posició de la barra de navegació " + addColumn: "Afegeix una columna" newNoteNotificationSettings: "Configuració de notificacions per a notes noves" configureColumn: "Configuració de columnes" swapLeft: "Mou a l’esquerra" @@ -2563,6 +2636,7 @@ _deck: mentions: "Mencions" direct: "Publicacions directes" roleTimeline: "Línia de temps dels rols" + chat: "Xat" _dialog: charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}" charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}" @@ -2659,6 +2733,7 @@ _moderationLogTypes: deletePage: "Esborrar la pàgina" deleteFlash: "Esborrar el guió" deleteGalleryPost: "Esborrar la publicació de la galeria" + deleteChatRoom: "Esborra la sala de xat" updateProxyAccountDescription: "Actualitzar descripció del compte proxy" _fileViewer: title: "Detall del fitxer" @@ -2673,10 +2748,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Assegura't que qui distribueix aquest recurs és fiable abans d'instal·lar-ho." _plugin: title: "Vols instal·lar aquest afegit?" - metaTitle: "Informació de l'afegit " _theme: title: "Vols instal·lar aquest tema?" - metaTitle: "Informació del tema" _meta: base: "Paleta de colors base" _vendorInfo: @@ -2729,7 +2802,7 @@ _hemisphere: _reversi: reversi: "Reversi" gameSettings: "Opcions del joc" - chooseBoard: "Escull un taulell" + chooseBoard: "Escull un tauler" blackOrWhite: "Negres/Blanques" blackIs: "{name} juga amb negres " rules: "Regles" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index a509280862..87f9445409 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -9,6 +9,9 @@ reset: "Obnovit" notifications: "Oznámení" username: "Uživatelské jméno" password: "Heslo" +initialPasswordForSetup: "Počáteční heslo pro nastavení" +initialPasswordIsIncorrect: "Počáteční heslo pro nastavení je nesprávné" +initialPasswordForSetupDescription: "Použijte heslo, které jste nastavili v konfiguračním souboru, pokud jste Misskey instalovali ručně.\nPokud užíváte Misskey hostovací službu, použijte poskytnuté heslo.\nPokud jste heslo nenastavovali, zanechte prázdné." forgotPassword: "Zapomenuté heslo" fetchingAsApObject: "Načítám data z Fediversu..." ok: "Potvrdit" @@ -46,6 +49,8 @@ pin: "Připnout" unpin: "Odepnout" copyContent: "Zkopírovat obsah" copyLink: "Kopírovat odkaz" +copyRemoteLink: "Zkoprírovat vzdálený odkaz" +copyLinkRenote: "Zkopírovat odkaz renotu" delete: "Smazat" deleteAndEdit: "Smazat a upravit" deleteAndEditConfirm: "Jste si jistí že chcete smazat tuto poznámku a editovat ji? Ztratíte tím všechny reakce, sdílení a odpovědi na ni." @@ -169,6 +174,9 @@ addAccount: "Přidat účet" reloadAccountsList: "Obnovit list účtů" loginFailed: "Přihlášení se nezdařilo." showOnRemote: "Více na původním profilu" +continueOnRemote: "Pokračujte na původní profil" +chooseServerOnMisskeyHub: "Vyberete si server z Misskey Hubu" +inputHostName: "Zadejte doménu" general: "Obecně" wallpaper: "Obrázek na pozadí" setWallpaper: "Nastavení obrázku na pozadí" @@ -193,6 +201,7 @@ perHour: "za hodinu" perDay: "za den" stopActivityDelivery: "Přestat zasílat aktivitu" blockThisInstance: "Blokovat tuto instanci" +silenceThisInstance: "Utišit tuto instanci" operations: "Operace" software: "Software" version: "Verze" @@ -257,7 +266,6 @@ removeAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" deleteAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" resetAreYouSure: "Opravdu resetovat?" saved: "Uloženo" -messaging: "Zprávy" upload: "Nahrát soubory" keepOriginalUploading: "Ponechat originální obrázek" keepOriginalUploadingDescription: "Uloží původní nahraný obrázek jak je. Pokud je to vypnuté, vygeneruje se zobrazení verze na webu při nahrátí." @@ -270,7 +278,6 @@ uploadFromUrlMayTakeTime: "Může trvat nějakou dobu, dokud nebude dokončeno n explore: "Objevovat" messageRead: "Přečtené" noMoreHistory: "To je vše" -startMessaging: "Zahájit chat" nUsersRead: "přečteno {n} uživateli" agreeTo: "Souhlasím s {0}" agree: "Souhlasím" @@ -449,8 +456,6 @@ retype: "Zadejte znovu" noteOf: "{user} poznámky" quoteAttached: "Citace" quoteQuestion: "Přiložit jako citaci?" -noMessagesYet: "Zatím tu nejsou žádné zprávy" -newMessageExists: "Máte novou zprávu" onlyOneFileCanBeAttached: "Ke zprávě můžete přiložit jenom jeden soubor" signinRequired: "Přihlašte se, prosím" invitations: "Pozvat" @@ -474,6 +479,8 @@ uiLanguage: "Jazyk uživatelského rozhraní" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Výchozí" +style: "Vzhled" +popup: "Vyskakovací okno" showNoteActionsOnlyHover: "Zobrazit akce poznámky jenom při naběhnutí myši" noHistory: "Žádná historie" signinHistory: "Historie přihlášení" @@ -536,6 +543,7 @@ showInPage: "Zobrazit na stránce" popout: "Pop-out" volume: "Hlasitost" masterVolume: "Celková hlasitost" +notUseSound: "Zakázat zvuk" details: "Detaily" chooseEmoji: "Vybrat emotikon" unableToProcess: "Operace nebyla dokončena." @@ -1099,6 +1107,13 @@ flip: "Otočit" lastNDays: "Posledních {n} dnů" surrender: "Zrušit" postForm: "Formulář pro odeslání" +information: "Informace" +_chat: + invitations: "Pozvat" + noHistory: "Žádná historie" + members: "Členové" + home: "Domů" + send: "Odeslat" _delivery: stop: "Suspendováno" _type: @@ -1611,7 +1626,6 @@ _theme: header: "Nadpis" navBg: "Pozadí postranního panelu" navFg: "Text na postranním panelu" - navHoverFg: "Text na postranním panelu (Hover)" navActive: "Text na postranním panelu (Aktivní)" navIndicator: "Indikátor na postranním panelu" link: "Odkaz" @@ -1634,11 +1648,8 @@ _theme: buttonHoverBg: "Pozadí tlačítka (Hover)" inputBorder: "Ohraničení vstupního pole" driveFolderBg: "Pozadí složky disku" - wallpaperOverlay: "Překrytí tapety" badge: "Odznak" messageBg: "Pozadí chatu" - accentDarken: "Akcent (Ztmavený)" - accentLighten: "Akcent (Zesvětlený)" fgHighlighted: "Zvýrazněný text" _sfx: note: "Poznámky" @@ -1714,6 +1725,7 @@ _permissions: "write:gallery": "Upravit galerii" "read:gallery-likes": "Zobrazit seznam to se mi líbí příspěvků v galerii" "write:gallery-likes": "Upravit seznam to se mi líbí příspěvků v galerii" + "write:chat": "Sestavit nebo mazat zprávy chatu" _auth: shareAccessTitle: "Udělovat oprávnění k aplikacím" shareAccess: "Chcete autorizovat \"{name}\" pro přístup k tomuto účtu?" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index db692d65e5..237603299c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "Möchtest du „{x}“ wirklich löschen?" resetAreYouSure: "Wirklich zurücksetzen?" areYouSure: "Bist du sicher?" saved: "Erfolgreich gespeichert" -messaging: "Chat" upload: "Hochladen" keepOriginalUploading: "Originalbild speichern" keepOriginalUploadingDescription: "Speichert das Originalbild so, wie es ist. Ist dies deaktiviert, wird eine Version zum Anzeigen im Internet generiert." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "Es kann eine Weile dauern, bis das Hochladen abgeschl explore: "Erkunden" messageRead: "Gelesen" noMoreHistory: "Kein weiterer Verlauf vorhanden" -startMessaging: "Neuen Chat erstellen" +startChat: "Chat starten" nUsersRead: "Von {n} Benutzern gelesen" agreeTo: "Ich stimme {0} zu" agree: "Zustimmen" @@ -425,6 +424,7 @@ antennaExcludeBots: "Bot-Accounts ausschließen" antennaKeywordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch trennen" notifyAntenna: "Über neue Notizen benachrichtigen" withFileAntenna: "Nur Notizen mit Dateien" +excludeNotesInSensitiveChannel: "Schließe Notizen von sensitive Kanäle aus" enableServiceworker: "Push-Benachrichtigungen im Browser aktivieren" antennaUsersDescription: "Benutzernamen getrennt durch Zeilenumbrüche angeben" caseSensitive: "Groß-/Kleinschreibung unterscheiden" @@ -491,8 +491,6 @@ noteOf: "Notiz von {user}" quoteAttached: "Zitat" quoteQuestion: "Als Zitat anhängen?" attachAsFileQuestion: "Der Text in der Zwischenablage ist lang. Möchtest du ihn als Textdatei anhängen?" -noMessagesYet: "Noch keine Nachrichten vorhanden" -newMessageExists: "Du hast eine neue Nachricht" onlyOneFileCanBeAttached: "Es kann pro Nachricht nur eine Datei angehängt werden" signinRequired: "Bitte registriere oder melde dich an, um fortzufahren" signinOrContinueOnRemote: "Um fortzufahren, gehe zu deiner Instanz oder registriere bzw. melde dich an dieser Instanz an. " @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name} sagt etwas über '{word}'" makeActive: "Aktivieren" display: "Anzeigeart" copy: "Kopieren" +copiedToClipboard: "In die Zwischenablage kopiert" metrics: "Metriken" overview: "Übersicht" logs: "Protokolle" @@ -964,8 +963,8 @@ cropImageAsk: "Möchtest du das Bild zuschneiden?" cropYes: "Zuschneiden" cropNo: "Unbearbeitet verwenden" file: "Datei" -recentNHours: "Letzten {n} Stunden" -recentNDays: "Letzten {n} Tage" +recentNHours: "Letzte {n} Stunden" +recentNDays: "Letzte {n} Tage" noEmailServerWarning: "Es ist kein Email-Server konfiguriert." thereIsUnresolvedAbuseReportWarning: "Es liegen ungelöste Meldungen vor." recommended: "Empfehlung" @@ -973,7 +972,7 @@ check: "Check" driveCapOverrideLabel: "Die Drive-Kapazität dieses Nutzers verändern" driveCapOverrideCaption: "Gib einen Wert von 0 oder weniger ein, um die Kapazität auf den Standard zurückzusetzen." requireAdminForView: "Melde dich mit einem Administratorkonto an, um dies einzusehen." -isSystemAccount: "Ein Benutzerkonto, dass durch das System erstellt und automatisch kontrolliert wird." +isSystemAccount: "Ein Benutzerkonto, das durch das System erstellt und automatisch verwaltet wird." typeToConfirm: "Bitte gib zur Bestätigung {x} ein" deleteAccount: "Benutzerkonto löschen" document: "Dokumentation" @@ -1144,7 +1143,7 @@ preventAiLearning: "Verwendung in machinellem Lernen (Generative bzw. Prediktive preventAiLearningDescription: "Fordert Crawler auf, gepostetes Text- oder Bildmaterial usw. nicht in Datensätzen für maschinelles Lernen (Generative bzw. Prediktive AI/KI) zu verwenden. Dies wird durch das Hinzufügen einer \"noai\"-Flag in der HTML-Antwort des jeweiligen Inhalts erreicht. Da diese Flag jedoch ignoriert werden kann, ist eine vollständige Verhinderung hierdurch nicht möglich." options: "Optionen" specifyUser: "Spezifischer Benutzer" -lookupConfirm: "Zustimmen?" +lookupConfirm: "Bist du sicher, dass du das nachschlagen möchtest?" openTagPageConfirm: "Hashtag Seite wirklich öffnen?" specifyHost: "Host" failedToPreviewUrl: "Vorschau nicht anzeigbar" @@ -1259,7 +1258,7 @@ replaying: "Aufzeichnung" endReplay: "Aufzeichnung verlassen" copyReplayData: "Aufzeichnung kopieren" ranking: "Rangliste" -lastNDays: "Letzten {n} Tage" +lastNDays: "Letzte {n} Tage" backToTitle: "Zurück zum Startbildschirm" hemisphere: "Hemisphäre" withSensitive: "Zeige \"sensitive Inhalte\" an" @@ -1306,20 +1305,149 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "Logge dich ein, um weitere Inhal lockdown: "Sperren" pleaseSelectAccount: "Bitte Konto auswählen" availableRoles: "Verfügbare Rollen" +acknowledgeNotesAndEnable: "Schalten Sie dies erst ein, wenn Sie die Vorsichtsmaßnahmen verstanden haben." federationSpecified: "Dieser Server arbeitet mit Whitelist-Föderation. Er kann nicht mit anderen als den vom Administrator angegebenen Servern interagieren." federationDisabled: "Föderation ist auf diesem Server deaktiviert. Es ist nicht möglich, mit Benutzern auf anderen Servern zu interagieren." +confirmOnReact: "Reagieren bestätigen" +reactAreYouSure: "Willst du eine \"{emoji}\"-Reaktion hinzufügen?" +markAsSensitiveConfirm: "Möchtest du dieses Medium als sensibel kennzeichnen?" +unmarkAsSensitiveConfirm: "Möchtest du die Kennzeichnung dieses Mediums als sensibel aufheben?" +preferences: "Einstellungen" +accessibility: "Eingabehilfe" +preferencesProfile: "Einstellungsprofil" +copyPreferenceId: "Kopiere die Einstellungs-ID" +resetToDefaultValue: "Auf Standard zurücksetzen" +overrideByAccount: "Überschreibung durch das Konto" +untitled: "Unbenannt" +noName: "Kein Name" +skip: "Überspringen" +restore: "Wiederherstellen" +syncBetweenDevices: "Zwischen Geräten synchronisieren" +preferenceSyncConflictTitle: "Der konfigurierte Wert ist auf dem Server bereits vorhanden." +preferenceSyncConflictText: "Die Einstellungen mit aktivierter Synchronisierung werden ihre Werte auf dem Server speichern. Es gibt jedoch bereits Werte auf dem Server. Welche Einstellungswerte sollen überschrieben werden?" +preferenceSyncConflictChoiceServer: "Konfigurierte Werte auf dem Server" +preferenceSyncConflictChoiceDevice: "Konfigurierte Werte auf dem Gerät" +preferenceSyncConflictChoiceCancel: "Einrichten der Synchronisierung abbrechen" +paste: "Einfügen" +emojiPalette: "Emoji-Palette" postForm: "Notizfenster" +textCount: "Zeichenanzahl" +information: "Über" +chat: "Chat" +migrateOldSettings: "Alte Client-Einstellungen migrieren" +migrateOldSettings_description: "Dies sollte normalerweise automatisch geschehen, aber wenn die Migration aus irgendeinem Grund nicht erfolgreich war, kannst du den Migrationsprozess selbst manuell auslösen. Die aktuellen Konfigurationsinformationen werden dabei überschrieben." +compress: "Komprimieren" +right: "Rechts" +bottom: "Unten" +top: "Oben" +embed: "Einbetten" +settingsMigrating: "Ihre Einstellungen werden gerade migriert, Bitte warten Sie einen Moment... (Sie können die Einstellungen später auch manuell migrieren, indem Sie zu Einstellungen → Sonstiges → Alte Einstellungen migrieren gehen)" +readonly: "Nur Lesezugriff" +goToDeck: "Zurück zum Deck" +_chat: + noMessagesYet: "Noch keine Nachrichten" + newMessage: "Neue Nachricht" + individualChat: "Privater Chat" + individualChat_description: "Führe einen privaten Chat mit einer anderen Person." + roomChat: "Chatraum" + roomChat_description: "Ein Chat-Raum, an dem mehrere Personen teilnehmen können.\nDu kannst auch Personen einladen, die keine privaten Chats zulassen, wenn sie die Einladung annehmen." + createRoom: "Raum erstellen" + inviteUserToChat: "Lade Benutzer ein, um mit dem Chatten zu beginnen" + yourRooms: "Erstellte Räume" + joiningRooms: "Raum beitreten" + invitations: "Einladen" + noInvitations: "Keine Einladungen" + history: "Verlauf" + noHistory: "Kein Verlauf gefunden" + noRooms: "Keine Räume gefunden" + inviteUser: "Benutzer einladen" + sentInvitations: "Verschickte Einladungen" + join: "Beitreten" + ignore: "Ignorieren" + leave: "Raum verlassen" + members: "Mitglieder" + searchMessages: "Nachrichten suchen" + home: "Startseite" + send: "Senden" + newline: "Neue Zeile" + muteThisRoom: "Raum stummschalten" + deleteRoom: "Raum löschen" + chatNotAvailableForThisAccountOrServer: "Der Chat ist auf diesem Server oder für dieses Konto nicht aktiviert." + chatIsReadOnlyForThisAccountOrServer: "Der Chat ist auf dieser Instanz oder diesem Konto nur zum Lesen freigegeben. Es ist nicht möglich, neue Nachrichten zu schreiben oder Chaträume zu erstellen oder zu betreten." + chatNotAvailableInOtherAccount: "Die Chatfunktion wurde vom anderen Benutzer deaktiviert." + cannotChatWithTheUser: "Starten eines Chats mit diesem Benutzer nicht möglich" + cannotChatWithTheUser_description: "Der Chat ist entweder nicht verfügbar oder die andere Seite hat den Chat nicht aktiviert." + chatWithThisUser: "Mit dem Benutzer chatten" + thisUserAllowsChatOnlyFromFollowers: "Dieser Benutzer nimmt nur Chats von Followern an." + thisUserAllowsChatOnlyFromFollowing: "Dieser Benutzer nimmt nur Chats von Benutzern an, denen er folgt." + thisUserAllowsChatOnlyFromMutualFollowing: "Dieser Benutzer akzeptiert nur Chats von Benutzern, die sich gegenseitig folgen." + thisUserNotAllowedChatAnyone: "Dieser Benutzer nimmt keine Chats von anderen Benutzern an." + chatAllowedUsers: "Wem das Chatten erlaubt werden soll" + chatAllowedUsers_note: "Du kannst unabhängig von dieser Einstellung mit allen Personen chatten, denen du eine Chat-Nachricht gesendet hast." + _chatAllowedUsers: + everyone: "Jeder" + followers: "Nur deine Follower" + following: "Nur Benutzer, denen du folgst" + mutual: "Nur Benutzer, die sich gegenseitig folgen" + none: "Niemand" +_emojiPalette: + palettes: "Palette" + enableSyncBetweenDevicesForPalettes: "Synchronisierung der Paletten zwischen Geräten aktivieren" + paletteForMain: "Hauptpalette" + paletteForReaction: "Reaktions-Palette" _settings: + driveBanner: "Du kannst den Drive verwalten und konfigurieren, die Auslastung überprüfen und Einstellungen für das Hochladen von Dateien vornehmen." + pluginBanner: "Du kannst die Funktionen des Clients mit Plugins erweitern. Plugins können installiert, individuell konfiguriert und verwaltet werden." + notificationsBanner: "Sie können die Arten und den Umfang der Benachrichtigungen vom Server und der Push- Mitteilungen konfigurieren." + api: "API" webhook: "Webhook" + serviceConnection: "Integrierte Dienste" + serviceConnectionBanner: "Du kannst Zugriffstoken und Webhooks für die Integration mit externen Anwendungen und Diensten verwalten und konfigurieren." + accountData: "Kontodaten" + accountDataBanner: "Export/Import und Verwaltung von Kontodatenarchiven." + muteAndBlockBanner: "Du kannst Einstellungen konfigurieren und verwalten, um Inhalte auszublenden und Aktionen für bestimmte Benutzer zu beschränken." + accessibilityBanner: "Die Clients können personalisiert und für eine optimale Nutzung im Hinblick auf ihre Darstellung und ihr Verhalten eingerichtet werden." + privacyBanner: "Du kannst Einstellungen für die Privatsphäre deines Kontos vornehmen, z. B. inwieweit Inhalte veröffentlicht werden, wie leicht sie zu finden sind und ob Follower genehmigt werden müssen." + securityBanner: "Du kannst Einstellungen für die Kontosicherheit konfigurieren, z. B. Passwörter, Anmeldemethoden, Authentifizierungs-Apps und Passkeys." + preferencesBanner: "Sie können das Gesamtverhalten des Clients nach Ihren Wünschen konfigurieren." + appearanceBanner: "Du kannst das Erscheinungsbild und die Anzeigeeinstellungen für den Client nach deinen Wünschen konfigurieren." + soundsBanner: "Du kannst die Einstellungen für die Wiedergabe von Klängen im Client konfigurieren." + timelineAndNote: "Chroniken und Notizen" + makeEveryTextElementsSelectable: "Alle Textelemente auswählbar machen" + makeEveryTextElementsSelectable_description: "Die Aktivierung kann in manchen Situationen die Benutzerfreundlichkeit beeinträchtigen." + useStickyIcons: "Icons beim Scrollen folgen lassen" + showNavbarSubButtons: "Unterschaltflächen in der Navigationsleiste anzeigen" + ifOn: "Wenn eingeschaltet" + ifOff: "Wenn ausgeschaltet" + enableSyncThemesBetweenDevices: "Synchronisierung von installierten Themen auf verschiedenen Endgeräten" + _chat: + showSenderName: "Name des Absenders anzeigen" + sendOnEnter: "Eingabetaste sendet Nachricht" +_preferencesProfile: + profileName: "Profilname" + profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert." + profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\"" +_preferencesBackup: + autoBackup: "Automatische Sicherung" + restoreFromBackup: "Wiederherstellen aus der Sicherung" + noBackupsFoundTitle: "Keine Sicherungen gefunden" + noBackupsFoundDescription: "Es wurden keine automatisch erstellten Sicherungen gefunden, aber wenn du eine Sicherungsdatei manuell gespeichert hast, kannst du diese importieren und wiederherstellen." + selectBackupToRestore: "Wähle die wiederherzustellende Sicherung" + youNeedToNameYourProfileToEnableAutoBackup: "Um die automatische Sicherung zu aktivieren, müssen Profilnamen festgelegt werden." + autoPreferencesBackupIsNotEnabledForThisDevice: "Die automatische Sicherung der Einstellungen ist auf diesem Gerät nicht aktiviert." + backupFound: "Konfigurationssicherung gefunden." _accountSettings: requireSigninToViewContents: "Anmeldung erfordern, um Inhalte anzuzeigen" requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln." requireSigninToViewContentsDescription2: "Der Inhalt wird nicht in URL-Vorschauen (OGP), eingebettet in Webseiten oder auf Servern, die keine Zitate unterstützen, angezeigt." requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern." makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar" + makeNotesFollowersOnlyBeforeDescription: "Solange diese Funktion aktiviert ist, sind Notizen, die nach dem eingestellten Datum und der eingestellten Zeit liegen oder die eingestellte Zeit abgelaufen ist, nur für Follower sichtbar. Bei Deaktivierung wird auch der öffentliche Status der Notiz wiederhergestellt." makeNotesHiddenBefore: "Frühere Notizen privat machen" makeNotesHiddenBeforeDescription: "" mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden." + mayNotEffectSomeSituations: "Diese Einschränkungen sind vereinfacht. Sie gelten möglicherweise nicht in allen Situationen, z. B. bei der Anzeige auf einem fremden Server oder während der Moderation." + notesHavePassedSpecifiedPeriod: "Notizen die nach der folgenden Zeit veröffentlicht worden" notesOlderThanSpecifiedDateAndTime: "Notizen vor einem bestimmtem Datum und Uhrzeit" _abuseUserReport: forward: "Weiterleiten" @@ -1327,11 +1455,16 @@ _abuseUserReport: resolve: "lösen" accept: "Akzeptieren" reject: "Ablehnen" + resolveTutorial: "Wenn der Inhalt der Meldung rechtmäßig ist, wähle „Akzeptieren“, um sie als gelöst zu markieren.\nWenn der Inhalt der Meldung unzulässig ist, wähle „Ablehnen“, um sie zu ignorieren." _delivery: + status: "Auslieferungsstatus" stop: "Gesperrt" + resume: "Zustellung wieder fortsetzen" _type: none: "Wird veröffentlicht" manuallySuspended: "Manuell gesperrt" + goneSuspended: "Gesperrt wegen Löschung des Servers" + autoSuspendedForNotResponding: "Gesperrt, weil der Server nicht antwortet" _bubbleGame: howToPlay: "Wie man spielt" hold: "Halten" @@ -1341,6 +1474,8 @@ _bubbleGame: highScore: "Höchstpunktzahl" maxChain: "Maximale Anzahl an Verkettungen" yen: "{yen} Yen" + estimatedQty: "{qty} Stück" + scoreSweets: "{onigiriQtyWithUnit} Onigiri" _howToPlay: section1: "Passe die Position an und lasse das Objekt in das Spielfeld fallen." section2: "Wenn sich zwei Objekte der gleichen Art berühren, verwandeln sie sich in ein anderes Objekt und du bekommst Punkte." @@ -1398,15 +1533,21 @@ _initialTutorial: reactDone: "Du kannst eine Reaktion zurücknehmen, indem du auf den '-' Button drückst." _timeline: title: "So funktionieren die Chroniken" + description1: "Misskey stellt mehrere Chroniken bereit (einige können je nach den Richtlinien des Servers nicht verfügbar sein)." home: "Du kannst Beiträge von den Konten sehen, denen du folgst." local: "Du kannst Beiträge aller Benutzer auf diesem Server sehen." social: "Notizen von der Startseite und der lokalen Chronik werden angezeigt." global: "Du kannst Notizen von allen föderierten Servern sehen." description2: "Du kannst jederzeit am oberen Rand des Bildschirms zwischen den jeweiligen Chroniken wechseln." + description3: "Darüber hinaus gibt es Listen-Chroniken und Kanal-Chroniken. Weitere Einzelheiten findest du unter {link}." _postNote: + title: "Optionen bei Abschicken einer Notiz" + description1: "Wenn du eine Notiz auf Misskey veröffentlichst, stehen dir verschiedene Optionen zur Verfügung. Die Oberfläche sieht folgendermaßen aus." _visibility: description: "Du kannst einschränken, wer deine Notiz sehen kann." public: "Deine Notiz wird für alle Nutzer sichtbar sein." + home: "Nur auf der Startseite sichtbar. Kann von Followern, Profilbesuchern und durch Renotes gesehen werden." + followers: "Nur für Follower sichtbar. Nur Follower können es sehen und niemand sonst, und es kann nicht von anderen gerenoted werden." direct: "Die Notiz wird nur für den angegebenen Benutzer veröffentlicht und der Empfänger wird benachrichtigt. Kann anstelle von Direktnachrichten verwendet werden." doNotSendConfidencialOnDirect1: "Sei vorsichtig, wenn du sensible Informationen verschickst!" doNotSendConfidencialOnDirect2: "Die Administratoren des Servers können den Inhalt der Notiz sehen. Sei vorsichtig mit sensiblen Informationen, wenn du Direktnachrichten an Benutzer auf nicht vertrauenswürdigen Servern sendest." @@ -1417,8 +1558,10 @@ _initialTutorial: _exampleNote: cw: "Das wird dich bestimmt hungrig machen!" note: "Ich hatte gerade einen Donut mit Schokoladenüberzug 🍩😋" + useCases: "Dient zur Kennzeichnung von Notizen, wie sie in den Serverrichtlinien vorgeschrieben sind, oder zur eigenen Festlegung von Spoiler-Beiträgen oder sensiblem Text." _howToMakeAttachmentsSensitive: title: "Wie markiert man Anhänge als sensibel?" + description: "Markiere Anhänge als sensibel, die aufgrund von den Serverregeln nicht sichtbar sein sollen." tryThisFile: "Versuche, das angehängte Bild als sensibel zu markieren!" _exampleNote: note: "Ups, ich habe es vergeigt, den Natto-Deckel zu öffnen..." @@ -1429,7 +1572,9 @@ _initialTutorial: title: "Du hast das Tutorial abgeschlossen! 🎉" description: "Die hier beschriebenen Funktionen sind nur ein kleiner Teil dessen, was Misskey zu bieten hat; um mehr darüber zu erfahren, wie du Misskey benutzen kannst, besuche bitte {link}." _timelineDescription: + home: "In der Startseiten-Chronik kannst du Notizen von Konten sehen, denen du folgst." local: "In der lokalen Chronik siehst du Notizen von allen Benutzern auf diesem Server." + social: "Die soziale Chronik zeigt Notizen von der Startseite und der lokalen Chronik." global: "In der globalen Chronik siehst du Notizen von allen föderierten Servern." _serverRules: description: "Eine Reihe von Regeln, die vor der Registrierung angezeigt werden. Eine Zusammenfassung der Nutzungsbedingungen anzuzeigen ist empfohlen." @@ -1446,6 +1591,9 @@ _serverSettings: fanoutTimelineDbFallback: "Auf die Datenbank zurückfallen" fanoutTimelineDbFallbackDescription: "Ist diese Option aktiviert, wird die Chronik auf zusätzliche Abfragen in der Datenbank zurückgreifen, wenn sich die Chronik nicht im Cache befindet. Eine Deaktivierung führt zu geringerer Serverlast, aber schränkt den Zeitraum der abrufbaren Chronik ein. " reactionsBufferingDescription: "Wenn diese Option aktiviert ist, kann sie die Leistung beim Erstellen von Reaktionen erheblich verbessern und die Belastung der Datenbank verringern. Allerdings steigt die Speichernutzung von Redis." + inquiryUrl: "Kontakt-URL" + inquiryUrlDescription: "Gib eine URL für das Kontaktformular der Serverbetreiber oder eine Webseite an, die Kontaktinformationen enthält." + openRegistration: "Registrierung von Konten aktivieren" openRegistrationWarning: "Das Aktivieren von Registrierungen ist riskant. Es wird empfohlen, sie nur dann zu aktivieren, wenn der Server ständig überwacht wird und im Falle eines Problems sofort reagiert werden kann." thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Wenn über einen bestimmten Zeitraum keine Moderatorenaktivität festgestellt wird, wird diese Einstellung automatisch deaktiviert, um Spam zu verhindern." _accountMigration: @@ -1710,8 +1858,11 @@ _achievements: description: "Tutorial abgeschlossen" _bubbleGameExplodingHead: title: "🤯" + description: "Das größte Objekt im Bubble Game" _bubbleGameDoubleExplodingHead: title: "Doppel🤯" + description: "Zwei der größten Objekte im Bubble Game zur gleichen Zeit" + flavor: "Eine Lunchbox kann man auch mit etwas mehr 🤯 🤯 füllen" _role: new: "Rolle erstellen" edit: "Rolle bearbeiten" @@ -1741,6 +1892,8 @@ _role: descriptionOfIsExplorable: "Ist dies aktiviert, so ist die Chronik dieser Rolle, sowie eine Liste der Benutzer mit dieser Rolle, frei zugänglich." displayOrder: "Position" descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." + preserveAssignmentOnMoveAccount: "Rolle übertragbar machen" + preserveAssignmentOnMoveAccount_description: "Wenn diese Option aktiviert ist, wird diese Rolle bei der Migration mit übertragen." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" descriptionOfCanEditMembersByModerator: "Wenn aktiviert, so können Moderatoren und Adminstratoren anderen Benutzern diese Rolle zuweisen bzw. diese Zuweisung aufheben. Wenn deaktiviert, so ist es nur Administratoren möglich, Zuweisungen dieser Rolle zu verwalten." priority: "Priorität" @@ -1761,6 +1914,7 @@ _role: canManageAvatarDecorations: "Profilbilddekorationen verwalten" driveCapacity: "Drive-Kapazität" alwaysMarkNsfw: "Dateien immer als NSFW markieren" + canUpdateBioMedia: "Kann ein Profil- oder ein Bannerbild bearbeiten" pinMax: "Maximale Anzahl an angehefteten Notizen" antennaMax: "Maximale Anzahl an Antennen" wordMuteMax: "Maximale Zeichenlänge für Wortstummschaltungen" @@ -1776,11 +1930,20 @@ _role: canUseTranslator: "Verwendung des Übersetzers" avatarDecorationLimit: "Maximale Anzahl an Profilbilddekorationen, die angebracht werden können" canImportAntennas: "Importieren von Antennen erlauben" + canImportBlocking: "Importieren von Blockierungen zulassen" + canImportFollowing: "Importieren von Gefolgten zulassen" + canImportMuting: "Importieren von Stummgeschalteten zulassen" + canImportUserLists: "Importieren von Listen erlauben" + chatAvailability: "Chatten erlauben" _condition: + roleAssignedTo: "Manuellen Rollen zugewiesen" isLocal: "Lokaler Benutzer" isRemote: "Benutzer fremder Instanz" isCat: "Katzen-Benutzer" isBot: "Bot-Benutzer" + isSuspended: "Gesperrter Benutzer" + isLocked: "Private Konten" + isExplorable: "Benutzer, die ihr Konto im \"Erkunden\"-Bereich sichtbar machen" createdLessThan: "Kontoerstellung liegt weniger als X zurück" createdMoreThan: "Kontoerstellung liegt mehr als X zurück" followersLessThanOrEq: "Hat X oder weniger Follower" @@ -1935,6 +2098,7 @@ _theme: installed: "{name} wurde installiert" installedThemes: "Installierte Farbschemata" builtinThemes: "Eingebaute Farbschemata" + instanceTheme: "Server-Thema" alreadyInstalled: "Dieses Farbschema ist bereits installiert" invalid: "Der Code dieses Farbschemas ist ungültig" make: "Farbschema erstellen" @@ -1967,7 +2131,6 @@ _theme: header: "Kopfzeile" navBg: "Hintergrund der Seitenleiste" navFg: "Text der Seitenleiste" - navHoverFg: "Text der Seitenleiste (Mouseover)" navActive: "Text der Seitenleiste (Aktiv)" navIndicator: "Indikator der Seitenleiste" link: "Link" @@ -1990,17 +2153,15 @@ _theme: buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" inputBorder: "Rahmen von Eingabefeldern" driveFolderBg: "Hintergrund von Drive-Ordnern" - wallpaperOverlay: "Hintergrundbild-Overlay" badge: "Wappen" messageBg: "Hintergrund von Chats" - accentDarken: "Akzent (Verdunkelt)" - accentLighten: "Akzent (Erhellt)" fgHighlighted: "Hervorgehobener Text" _sfx: note: "Notizen" noteMy: "Meine Notizen" notification: "Benachrichtigungen" reaction: "Auswählen einer Reaktion" + chatMessage: "Chat-Nachrichten" _soundSettings: driveFile: "Audiodatei aus dem Drive verwenden" driveFileWarn: "Wähle eine Audiodatei aus dem Drive" @@ -2024,6 +2185,10 @@ _timeIn: seconds: "In {n}s" minutes: "In {n} Min." hours: "In {n} Std." + days: "In {n} Tagen" + weeks: "In {n} Wochen" + months: "In {n} Monaten" + years: "In {n} Jahren" _time: second: "Sekunde(n)" minute: "Minute(n)" @@ -2057,6 +2222,7 @@ _2fa: backupCodesDescription: "Verwende diese Codes, falls du nicht mehr auf deine App zur Zweifaktorauthentifizierung zugreifen kannst. Jeder Code kann nur einmal verwendet werden. Bewahre sie an einem sicheren Ort auf." backupCodeUsedWarning: "Ein Backup-Code wurde verwendet. Falls du den Zugriff zu deiner Zweifaktorauthentifizierungsapp verloren hast, konfiguriere diese bitte möglichst bald erneut." backupCodesExhaustedWarning: "Alle Backup-Codes wurden verwendet. Falls du den Zugang zu deiner Zweifaktorauthentifizierungsapp verlierst, wirst du dich nicht mehr in dieses Konto einloggen können. Bitte konfiguriere diese App erneut." + moreDetailedGuideHere: "Hier ist eine ausführliche Anleitung" _permissions: "read:account": "Deine Benutzerkontoinformationen lesen" "write:account": "Deine Benutzerkontoinformationen bearbeiten" @@ -2094,6 +2260,7 @@ _permissions: "write:flash": "Deine Plays bearbeiten oder löschen" "read:flash-likes": "Liste der Plays, die mir gefallen, lesen" "write:flash-likes": "Liste der Plays, die mir gefallen, bearbeiten" + "read:admin:abuse-user-reports": "Meldungen von Benutzern ansehen" "write:admin:delete-account": "Benutzerkonto löschen" "write:admin:delete-all-files-of-a-user": "Alle Dateien eines Benutzers löschen" "read:admin:index-stats": "Statistiken zu Datenbankindizes einsehen" @@ -2101,10 +2268,17 @@ _permissions: "read:admin:user-ips": "IP-Adressen von Benutzern anzeigen" "read:admin:meta": "Metadaten der Instanz einsehen" "write:admin:reset-password": "Benutzerpasswort zurücksetzen" + "write:admin:resolve-abuse-user-report": "Meldungen von Benutzern lösen" "write:admin:send-email": "E-Mail versenden" "read:admin:server-info": "Serverinformationen anzeigen" "read:admin:show-moderation-log": "Moderationsprotokoll einsehen" "read:admin:show-user": "Private Benutzerinformationen einsehen" + "write:admin:suspend-user": "Benutzer sperren" + "write:admin:unset-user-avatar": "Benutzer-Profilbild entfernen" + "write:admin:unset-user-banner": "Benutzer-Banner entfernen" + "write:admin:unsuspend-user": "Benutzer entsperren" + "write:admin:meta": "Metadaten der Instanz verwalten" + "write:admin:user-note": "Moderationsvermerke verwalten" "write:admin:roles": "Rollen verwalten" "read:admin:roles": "Rollen anzeigen" "write:admin:relays": "Relays verwalten" @@ -2115,12 +2289,27 @@ _permissions: "read:admin:announcements": "Ankündigungen einsehen" "write:admin:avatar-decorations": "Kann Avatar-Dekorationen verwalten" "read:admin:avatar-decorations": "Avatar-Dekorationen ansehen" + "write:admin:federation": "Informationen über Föderationen bearbeiten oder löschen" "write:admin:account": "Benutzerkonten verwalten" "read:admin:account": "Benutzerkonten anzeigen" "write:admin:emoji": "Emojis verwalten" "read:admin:emoji": "Emojis anzeigen" "write:admin:queue": "Job-Warteschlange verwalten" "read:admin:queue": "Job-Warteschlange anzeigen" + "write:admin:promo": "Moderationsnotiz hinzufügen" + "write:admin:drive": "Benutzer-Drive verwalten" + "read:admin:drive": "Benutzer-Drive ansehen" + "read:admin:stream": "Verwendung der Websocket-API für Administratoren" + "write:admin:ad": "Werbung verwalten" + "read:admin:ad": "Werbung ansehen" + "write:invite-codes": "Einladungscodes erstellen" + "read:invite-codes": "Einladungscodes anzeigen" + "write:clip-favorite": "Clip-Likes bearbeiten oder löschen" + "read:clip-favorite": "Clip-Likes ansehen" + "read:federation": "Informationen zur Föderation einsehen" + "write:report-abuse": "Verstöße melden" + "write:chat": "Chats bedienen" + "read:chat": "Chats durchsuchen" _auth: shareAccessTitle: "Verteilung von App-Berechtigungen" shareAccess: "Möchtest du „{name}“ authorisieren, auf dieses Benutzerkonto zugreifen zu können?" @@ -2131,7 +2320,9 @@ _auth: callback: "Es wird zur Anwendung zurückgekehrt" accepted: "Zugriff gewährt" denied: "Zugriff verweigert" + scopeUser: "Als folgender Benutzer agieren" pleaseLogin: "Bitte logge dich ein, um Apps zu authorisieren." + byClickingYouWillBeRedirectedToThisUrl: "Wenn der Zugang gewährt wird, wirst du automatisch zu folgender URL weitergeleitet" _antennaSources: all: "Alle Notizen" homeTimeline: "Notizen von Benutzern, denen gefolgt wird" @@ -2177,6 +2368,7 @@ _widgets: chooseList: "Liste auswählen" clicker: "Klickzähler" birthdayFollowings: "Nutzer, die heute Geburtstag haben" + chat: "Chat" _cw: hide: "Inhalt verbergen" show: "Inhalt anzeigen" @@ -2242,6 +2434,7 @@ _profile: avatarDecorationMax: "Du kannst bis zu {max} Dekorationen hinzufügen." followedMessage: "Nachricht, wenn dir jemand folgt" followedMessageDescription: "Du kannst eine kurze Nachricht festlegen, die dem Empfänger angezeigt wird, wenn er dir folgt." + followedMessageDescriptionForLockedAccount: "Wenn Folgeanfragen deine Genehmigung brauchen, wird dies beim Genehmigen einer Anfrage angezeigt." _exportOrImport: allNotes: "Alle Notizen" favoritedNotes: "Als Favorit markierte Notizen" @@ -2299,6 +2492,7 @@ _play: title: "Titel" script: "Skript" summary: "Beschreibung" + visibilityDescription: "Wenn du die Sichtbarkeit auf Privat stellst, wird der Play nicht auf deinem Profil sichtbar sein, aber jeder, der die URL hat, kann ihn trotzdem aufrufen." _pages: newPage: "Seite erstellen" editPage: "Seite bearbeiten" @@ -2330,6 +2524,7 @@ _pages: eyeCatchingImageSet: "Vorschaubild festlegen" eyeCatchingImageRemove: "Vorschaubild entfernen" chooseBlock: "Block hinzufügen" + enterSectionTitle: "Titel des Abschnitts eingeben" selectType: "Typ auswählen" contentBlocks: "Inhalt" inputBlocks: "Eingabe" @@ -2340,6 +2535,8 @@ _pages: section: "Abschnitt" image: "Bild" button: "Knopf" + dynamic: "Dynamische Bausteine" + dynamicDescription: "Dieser Baustein wurde abgeschafft. Bitte verwende von nun an {play}." note: "Eingebettete Notiz" _note: id: "Notiz-ID" @@ -2362,6 +2559,7 @@ _notification: newNote: "Neue Notiz" unreadAntennaNote: "Antenne {name}" roleAssigned: "Rolle zugewiesen" + chatRoomInvitationReceived: "Du wurdest in einen Chatraum eingeladen" emptyPushNotificationMessage: "Push-Benachrichtigungen wurden aktualisiert" achievementEarned: "Errungenschaft freigeschaltet" testNotification: "Testbenachrichtigung" @@ -2375,6 +2573,8 @@ _notification: flushNotification: "Benachrichtigungen löschen" exportOfXCompleted: "Der Export von {x} ist abgeschlossen" login: "Neue Anmeldung erfolgt" + createToken: "Ein Zugangstoken wurde erstellt" + createTokenDescription: "Wenn Sie keine Ahnung haben, löschen Sie das Zugriffstoken über \"{text}\"" _types: all: "Alle" note: "Neue Notizen" @@ -2388,9 +2588,11 @@ _notification: receiveFollowRequest: "Erhaltene Follow-Anfragen" followRequestAccepted: "Akzeptierte Follow-Anfragen" roleAssigned: "Rolle zugewiesen" + chatRoomInvitationReceived: "Einladungen zum Chatraum" achievementEarned: "Errungenschaft freigeschaltet" exportCompleted: "Der Export ist abgeschlossen" login: "Anmeldung" + createToken: "Erstellung von Zugriffstokens" test: "Test-Benachrichtigungen" app: "Benachrichtigungen von Apps" _actions: @@ -2400,6 +2602,9 @@ _notification: _deck: alwaysShowMainColumn: "Hauptspalte immer zeigen" columnAlign: "Spaltenausrichtung" + columnGap: "Spaltenabstand" + deckMenuPosition: "Position des Deck-Menüs" + navbarPosition: "Position der Navigationsleiste" addColumn: "Spalte hinzufügen" newNoteNotificationSettings: "Benachrichtigungseinstellungen für neue Notizen" configureColumn: "Spalteneinstellungen" @@ -2418,6 +2623,7 @@ _deck: useSimpleUiForNonRootPages: "Simple Benutzeroberfläche für navigierte Seiten verwenden" usedAsMinWidthWhenFlexible: "Ist \"Automatische Breitenanpassung\" aktiviert, wird hierfür die minimale Breite verwendet" flexible: "Automatische Breitenanpassung" + enableSyncBetweenDevicesForProfiles: "Aktivieren der Synchronisierung von Profilinformationen zwischen Geräten" _columns: main: "Hauptspalte" widgets: "Widgets" @@ -2429,6 +2635,7 @@ _deck: mentions: "Erwähnungen" direct: "Direktnachrichten" roleTimeline: "Rollenchronik" + chat: "Chat" _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" @@ -2453,7 +2660,14 @@ _webhookSettings: renote: "Wenn du ein Renote erhältst" reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" + _systemEvents: + abuseReport: "Wenn eine neue Meldung eingeht" + abuseReportResolved: "Wenn eine Meldung gelöst wird" + userCreated: "Beim Anlegen eines Benutzers" + inactiveModeratorsWarning: "Wenn Moderatoren für eine gewisse Zeit inaktiv sind" + inactiveModeratorsInvitationOnlyChanged: "Wenn ein Moderator über einen gewissen Zeitraum inaktiv war und der Server auf Einladungsbasis umgestellt wird" deleteConfirm: "Bist du sicher, dass du den Webhook löschen willst?" + testRemarks: "Klicke auf die Schaltfläche rechts neben dem Schalter, um einen Test-Webhook mit Dummy-Daten zu senden." _abuseReport: _notificationRecipient: createRecipient: "Meldungsempfänger hinzufügen" @@ -2493,9 +2707,12 @@ _moderationLogTypes: resetPassword: "Passwort zurückgesetzt" suspendRemoteInstance: "Fremde Instanz gesperrt" unsuspendRemoteInstance: "Fremde Instanz entsperrt" + updateRemoteInstanceNote: "Aktualisierung der Moderationshinweise für fremde Server." markSensitiveDriveFile: "Datei als sensitiv markiert" unmarkSensitiveDriveFile: "Datei als nicht sensitiv markiert" resolveAbuseReport: "Meldung bearbeitet" + forwardAbuseReport: "Meldung weitergeleitet" + updateAbuseReportNote: "Moderationsnotiz einer Meldung aktualisiert" createInvitation: "Einladung erstellt" createAd: "Werbung erstellt" deleteAd: "Werbung gelöscht" @@ -2508,9 +2725,15 @@ _moderationLogTypes: createSystemWebhook: "System-Webhook erstellt" updateSystemWebhook: "System-Webhook aktualisiert" deleteSystemWebhook: "System-Webhook gelöscht" + createAbuseReportNotificationRecipient: "Empfänger für Meldungen erstellt" + updateAbuseReportNotificationRecipient: "Empfänger für Meldungen aktualisiert" + deleteAbuseReportNotificationRecipient: "Empfänger für Meldungen entfernt" deleteAccount: "Benutzerkonto gelöscht" deletePage: "Seite gelöscht" + deleteFlash: "Play gelöscht" deleteGalleryPost: "Galeriebeitrag gelöscht" + deleteChatRoom: "Chatraum gelöscht" + updateProxyAccountDescription: "Beschreibung des Proxy-Benutzerkontos aktualisiert" _fileViewer: title: "Dateiinformationen" type: "Dateityp" @@ -2524,10 +2747,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Überprüfe vor Installation die Vertrauenswürdigkeit des Vertreibers." _plugin: title: "Möchtest du dieses Plugin installieren?" - metaTitle: "Plugininformation" _theme: title: "Möchten du dieses Farbschema installieren?" - metaTitle: "Farbschemainfo" _meta: base: "Farbschemavorlage" _vendorInfo: @@ -2560,16 +2781,67 @@ _externalResourceInstaller: _themeInstallFailed: title: "Das Farbschema konnte nicht installiert werden" description: "Während der Installation des Farbschemas ist ein Problem aufgetreten. Bitte versuche es erneut. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." +_dataSaver: + _media: + title: "Laden von Medien verhindern" + description: "Verhindert, dass Bilder/Videos automatisch geladen werden. Ausgeblendete Bilder/Videos werden geladen, wenn du auf sie tippst." + _avatar: + title: "Animierte Profilbilder deaktivieren" + description: "Die Animation von Profilbildern wird angehalten. Da animierte Bilder eine größere Dateigröße haben können als normale Bilder, kann dies den Datenverkehr weiter reduzieren." + _urlPreview: + title: "URL-Vorschaubilder ausblenden" + description: "URL-Vorschaubilder werden nicht mehr geladen." + _code: + title: "Code-Hervorhebungen ausblenden" + description: "Wenn Code-Hervorhebungen in MFM usw. verwendet werden, werden sie erst geladen, wenn sie angetippt werden. Die Syntaxhervorhebung erfordert das Herunterladen der Definitionsdateien für jede Programmiersprache. Es ist daher zu erwarten, dass die Deaktivierung des automatischen Ladens dieser Dateien die Menge des Datenverkehrs reduziert." _hemisphere: N: "Nördliche Erdhalbkugel" S: "Südliche Erdhalbkugel" caption: "Wird in einigen Client-Einstellungen zur Bestimmung der Jahreszeit verwendet." _reversi: + reversi: "Reversi" + gameSettings: "Spieleinstellungen" + chooseBoard: "Spielbrett auswählen" blackOrWhite: "Schwarz/Weiß" + blackIs: "{name} spielt Schwarz" rules: "Regeln" + thisGameIsStartedSoon: "Das Spiel wird in Kürze beginnen" + waitingForOther: "Warte auf den Zug des Gegenspielers" + waitingForMe: "Warte auf deinen Zug" + waitingBoth: "Mach dich bereit" + ready: "Bereit" + cancelReady: "Nicht bereit" + opponentTurn: "Dein Gegner ist an der Reihe" + myTurn: "Du bist am Zug" + turnOf: "{name} ist am Zug" + pastTurnOf: "Zug von {name}" + surrender: "Aufgeben" + surrendered: "Aufgegeben" + timeout: "Zeit abgelaufen" + drawn: "Unentschieden" + won: "{name} hat gewonnen" black: "Schwarz" white: "Weiß" total: "Gesamt" + turnCount: " Zug {count}" + myGames: "Meine Runden" + allGames: "Alle Runden" + ended: "Beendet" + playing: "Partie läuft" + isLlotheo: "Der mit weniger Steinen gewinnt (Llotheo)" + loopedMap: "Wiederholendes Spielbrett" + canPutEverywhere: "Steine können überall platziert werden" + timeLimitForEachTurn: "Zeitlimit eines Zugs" + freeMatch: "Freies Spiel" + lookingForPlayer: "Gegner werden gesucht..." + gameCanceled: "Das Spiel wurde abgesagt." + shareToTlTheGameWhenStart: "Spiel in der Chronik teilen, wenn es gestartet wurde" + iStartedAGame: "Das Spiel hat begonnen! #MisskeyReversi" + opponentHasSettingsChanged: "Der Gegner hat seine Einstellungen geändert." + allowIrregularRules: "Irreguläre Regeln (völlig frei)" + disallowIrregularRules: "Keine irregulären Regeln" + showBoardLabels: "Anzeige der Zeilen- und Spaltennummern am Spielbrett" + useAvatarAsStone: "Steine in Benutzeravatare umwandeln" _offlineScreen: title: "Offline - keine Verbindung zum Server möglich" header: "Verbindung zum Server nicht möglich" @@ -2583,14 +2855,79 @@ _urlPreviewSetting: requireContentLength: "Vorschau nur generieren, wenn Content-Length verfügbar ist" requireContentLengthDescription: "Wenn der Server keine Content-Length zurückgibt, wird keine Vorschau erzeugt." userAgent: "User-Agent" + userAgentDescription: "Legt den User-Agent fest, der beim Abrufen der Vorschau verwendet werden soll. Bleibt er leer, wird der Standard-User-Agent verwendet." + summaryProxy: "Proxy-Endpunkte, die Vorschaubilder erzeugen" + summaryProxyDescription: "Generierung von Vorschaubildern mit Summaly Proxy anstelle von Misskey selbst." + summaryProxyDescription2: "Die folgenden Parameter werden als Abfrage-Strings mit dem Proxy verknüpft. Wenn der Proxy sie nicht unterstützt, werden die Werte ignoriert." _mediaControls: + pip: "Bild-in-Bild" playbackRate: "Wiedergabegeschwindigkeit" + loop: "Endloswiedergabe" _contextMenu: title: "Kontextmenü" app: "Anwendung" + appWithShift: "Anwendung per Umschalttaste" + native: "Natives Browsermenü" _gridComponent: _error: requiredValue: "Dieser Wert ist ein Pflichtfeld" + columnTypeNotSupport: "Die Validierung regulärer Ausdrücke wird nur für Spalten vom Typ \"Text\" unterstützt." + patternNotMatch: "Dieser Wert stimmt nicht mit dem Schema in {pattern} überein" + notUnique: "Dieser Wert muss eindeutig sein" +_roleSelectDialog: + notSelected: "Nicht ausgewählt" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Ausgewählte Zeilen kopieren" + copySelectionRanges: "Auswahl kopieren" + deleteSelectionRows: "Ausgewählte Zeilen löschen" + deleteSelectionRanges: "Zeilen in der Auswahl löschen" + searchSettings: "Sucheinstellungen" + searchSettingCaption: "Detaillierte Suchkriterien festlegen." + searchLimit: "Anzahl der Ergebnisse" + sortOrder: "Sortierung" + registrationLogs: "Registrierungsprotokoll" + registrationLogsCaption: "Protokolle werden beim Aktualisieren oder Löschen von Emojis angezeigt. Sie verschwinden nach dem Aktualisieren oder Löschen, dem Wechsel zu einer neuen Seite oder dem Neuladen." + alertEmojisRegisterFailedDescription: "Emoji konnte nicht aktualisiert oder gelöscht werden. Bitte prüfe das Registrierungsprotokoll für Details." + _logs: + showSuccessLogSwitch: "Erfolgsprotokoll zeigen" + failureLogNothing: "Es gibt kein Fehlerprotokoll." + logNothing: "Keine Protokoll-Einträge." + _remote: + selectionRowDetail: "Details der ausgewählten Zeile" + importSelectionRows: "Ausgewählte Zeilen importieren" + importSelectionRangesRows: "Zeilen in der Auswahl importieren" + importEmojisButton: "Ausgewählte Emojis importieren" + confirmImportEmojisTitle: "Emojis importieren" + confirmImportEmojisDescription: "Importiere {count} Emoji(s), die von entfernten Server empfangen wurden. Bitte achte genau auf die Lizenz der Emojis. Bist du sicher, dass du fortfahren möchtest?" + _local: + tabTitleList: "Hinzugefügte Emojis" + tabTitleRegister: "Emojis hinzufügen" + _list: + emojisNothing: "Es wurden keine Emojis hinzugefügt." + markAsDeleteTargetRows: "Ausgewählte Zeilen als zu löschendes Element markieren" + markAsDeleteTargetRanges: "Zeilen in der Auswahl als zu löschendes Element markieren" + alertUpdateEmojisNothingDescription: "Es wurden keine Emojis geändert." + alertDeleteEmojisNothingDescription: "Es gibt keine zu löschenden Emojis." + confirmMovePage: "Möchten Sie die Seiten verschieben?" + confirmChangeView: "Möchten Sie die Darstellung wechseln?" + confirmUpdateEmojisDescription: "Aktualisiere {count} Emoji(s). Willst du fortfahren?" + confirmDeleteEmojisDescription: "Lösche {count} ausgewählte Emoji(s). Willst du fortfahren?" + confirmResetDescription: "Alle bisher vorgenommenen Änderungen werden zurückgesetzt." + confirmMovePageDesciption: "An den Emojis auf dieser Seite wurden Änderungen vorgenommen.\nWenn du die Seite verlässt, ohne zu speichern, werden alle auf dieser Seite vorgenommenen Änderungen verworfen." + dialogSelectRoleTitle: "Suche nach dem Rollensatz in Emojis" + _register: + uploadSettingTitle: "Upload-Einstellungen" + uploadSettingDescription: "Hier kannst du das Verhalten beim Hochladen von Emojis konfigurieren." + directoryToCategoryLabel: "Gib den Namen des Verzeichnisses in das Feld „Kategorie“ ein" + directoryToCategoryCaption: "Wenn du ein Verzeichnis ziehst und ablegst, gib den Verzeichnisnamen in das Feld „Kategorie“ ein." + emojiInputAreaCaption: "Wählen Sie die Emojis aus, die Sie mit einer der folgenden Methoden speichern möchten." + emojiInputAreaList1: "Ziehe Bilddateien oder Verzeichnisse per Drag-and-drop in diesen Rahmen" + emojiInputAreaList2: "Klicke auf diesen Link, um von deinem PC aus zu wählen" + emojiInputAreaList3: "Klicke auf diesen Link, um vom Drive aus zu wählen" + confirmRegisterEmojisDescription: "Füge die in der Liste aufgeführten Emojis als neue benutzerdefinierte Emojis hinzu. Bist du sicher? (Um eine Überlastung zu vermeiden, können nur {count} Emoji(s) in einem Vorgang hinzugefügt werden)" + confirmClearEmojisDescription: "Verwerfe die Bearbeitungen und lösche die Emojis aus der Liste. Bist du sicher, dass du fortfahren möchtest?" + confirmUploadEmojisDescription: "Lade die {count} abgelegte(n) Datei(en) in das Drive hoch. Bist du sicher, dass du fortfahren möchtest?" _embedCodeGen: title: "Einbettungscode anpassen" header: "Kopfzeile anzeigen" @@ -2598,6 +2935,9 @@ _embedCodeGen: maxHeight: "Maximale Höhe" maxHeightDescription: "Der Wert 0 deaktiviert die Einstellung der maximalen Höhe. Gib einen Wert an, um zu verhindern, dass das Widget weiterhin vertikal vergrößert wird." maxHeightWarn: "Die Begrenzung der maximalen Höhe ist deaktiviert (0). Wenn dies nicht beabsichtigt war, setze die maximale Höhe auf einen Wert fest." + previewIsNotActual: "Die Anzeige weicht von der tatsächlichen Einbettung ab, da sie den auf dem Vorschaufenster angezeigten Bereich überschreitet." + rounded: "Ecken abrunden" + border: "Dem äußeren Rand einen Rahmen hinzufügen" applyToPreview: "Auf die Vorschau anwenden" generateCode: "Einbettungscode generieren" codeGenerated: "Der Code wurde generiert" @@ -2606,7 +2946,11 @@ _selfXssPrevention: warning: "WARNUNG" title: "„Füge in diesen Bereich etwas ein“ ist eine Betrugsmasche." description1: "Wenn du hier etwas einfügst, könnte ein böswilliger Benutzer dein Konto übernehmen oder deine persönlichen Daten stehlen." + description2: "Wenn du das nicht genau verstehst, was du einfügst, %csolltest du die Eingabe abbrechen und das Fenster schließen." description3: "Weitere Informationen findest du hier. {link}" +_followRequest: + recieved: "Anfrage erhalten" + sent: "Anfrage gesendet" _remoteLookupErrors: _federationNotAllowed: title: "Kommunikation mit diesem Server nicht möglich" @@ -2614,10 +2958,45 @@ _remoteLookupErrors: _uriInvalid: title: "URI ist fehlerhaft" description: "Es gibt ein Problem mit der von dir eingegebenen URI. Bitte prüfe, ob du Zeichen eingegeben hast, die in der URI nicht verwendet werden können." + _requestFailed: + title: "Anfrage fehlgeschlagen" + description: "Die Kommunikation mit diesem Server ist fehlgeschlagen. Der Server ist möglicherweise nicht erreichbar. Bitte vergewissere dich auch, dass du keine ungültige oder nicht existierende URI eingegeben hast." + _responseInvalid: + title: "Die Antwort ist ungültig" + description: "Die Kommunikation mit dem Server war erfolgreich, aber die erhaltenen Daten waren nicht korrekt. Wenn du Remote-Inhalte über einen Server eines Dritten abfragst, verwende bitte erneut eine URI, die vom Ursprungsserver abgerufen werden kann." _noSuchObject: title: "Nicht gefunden" description: "Die angeforderte Ressource konnte nicht gefunden werden, bitte überprüfe die URI erneut." +_captcha: + verify: "Bitte beantworte das CAPTCHA" + testSiteKeyMessage: "Du kannst die Vorschau prüfen, indem du die Testwerte für den Site- und Secret-Key eingibst. Weitere Informationen findest du auf der folgenden Seite." + _error: + _requestFailed: + title: "CAPTCHA-Anfrage fehlgeschlagen." + text: "Bitte probiere es später noch einmal oder überprüfe die Einstellungen erneut." + _verificationFailed: + title: "CAPTCHA-Prüfung fehlgeschlagen" + text: "Bitte überprüfe nochmals, ob die Einstellungen korrekt sind." + _unknown: + title: "CAPTCHA-Fehler" + text: "Es ist ein unerwarteter Fehler aufgetreten." +_bootErrors: + title: "Laden fehlgeschlagen" + serverError: "Wenn das Problem nach kurzem Warten und erneutem Laden immer noch nicht behoben ist, wende dich bitte an den Serveradministrator und gib die folgende Fehler-ID an." + solution: "Folgendes könnte das Problem lösen." + solution1: "Aktualisiere deinen Browser und dein Betriebssystem auf die neueste Version" + solution2: "Deaktiviere den Werbeblocker" + solution3: "Leere den Browser-Cache" + solution4: "(Tor Browser) Setze dom.webaudio.enabled auf true" + otherOption: "Weitere Optionen" + otherOption1: "Client-Einstellungen und Cache löschen" + otherOption2: "Einfachen Client starten" + otherOption3: "Starte das Reparaturwerkzeug" _search: searchScopeAll: "Alle" searchScopeLocal: "Lokal" + searchScopeServer: "Bestimmter Server" searchScopeUser: "Spezifischer Benutzer" + pleaseEnterServerHost: "Gib den Server-Host ein" + pleaseSelectUser: "Benutzer auswählen" + serverHostPlaceholder: "Beispiel: misskey.example.com" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index be81a824c7..c8aff304d2 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -162,14 +162,12 @@ imageUrl: "URL εικόνας" remove: "Διαγραφή" removed: "Η διαγραφή ολοκληρώθηκε επιτυχώς" saved: "Αποθηκεύτηκε" -messaging: "Συνομιλία" upload: "Ανεβάστε" fromDrive: "Από τον Αποθηκευτικό Χώρο" fromUrl: "Από URL" uploadFromUrl: "Ανεβάστε από URL" explore: "Εξερευνήστε" messageRead: "Διαβάστηκε" -startMessaging: "Ξεκινήστε μία συνομιλία" nUsersRead: "διαβάστηκε από {n}" start: "Ας αρχίσουμε" home: "Κεντρικό" @@ -289,6 +287,10 @@ icon: "Εικονίδιο" replies: "Απάντηση" renotes: "Κοινοποίηση σημειώματος" postForm: "Φόρμα δημοσίευσης" +information: "Πληροφορίες" +_chat: + members: "Μέλη" + home: "Κεντρικό" _email: _follow: title: "Έχετε ένα νέο ακόλουθο" @@ -322,6 +324,7 @@ _permissions: "write:notifications": "Διαχειριστείτε τις ειδοποιήσεις σας" "read:pages": "Δείτε τις Σελίδες σας" "write:pages": "Επεξεργαστείτε ή διαγράψτε τις σελίδες σας" + "write:chat": "Γράψτε ή διαγράψτε μηνύματα συνομιλίας" _antennaSources: all: "Όλα τα σημειώματα" homeTimeline: "Σημειώματα από μέλη που ακολουθείτε" diff --git a/locales/en-US.yml b/locales/en-US.yml index 0d2a29edca..533682fd4f 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?" resetAreYouSure: "Really reset?" areYouSure: "Are you sure?" saved: "Saved" -messaging: "Chat" upload: "Upload" keepOriginalUploading: "Keep original image" keepOriginalUploadingDescription: "Saves the originally uploaded image as-is. If turned off, a version to display on the web will be generated on upload." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "It may take some time until the upload is complete." explore: "Explore" messageRead: "Read" noMoreHistory: "There is no further history" -startMessaging: "Start a new chat" +startChat: "Start chat" nUsersRead: "read by {n}" agreeTo: "I agree to {0}" agree: "Agree" @@ -346,7 +345,7 @@ emptyDrive: "Your Drive is empty" emptyFolder: "This folder is empty" unableToDelete: "Unable to delete" inputNewFileName: "Enter a new filename" -inputNewDescription: "Enter new caption" +inputNewDescription: "Enter new alt text" inputNewFolderName: "Enter a new folder name" circularReferenceFolder: "The destination folder is a subfolder of the folder you wish to move." hasChildFilesOrFolders: "Since this folder is not empty, it can not be deleted." @@ -425,6 +424,7 @@ antennaExcludeBots: "Exclude bot accounts" antennaKeywordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition." notifyAntenna: "Notify about new notes" withFileAntenna: "Only notes with files" +excludeNotesInSensitiveChannel: "Exclude notes from sensitive channels" enableServiceworker: "Enable Push-Notifications for your Browser" antennaUsersDescription: "List one username per line" caseSensitive: "Case sensitive" @@ -491,8 +491,6 @@ noteOf: "Note by {user}" quoteAttached: "Quote" quoteQuestion: "Append as quote?" attachAsFileQuestion: "The text in clipboard is long. Would you want to attach it as text file?" -noMessagesYet: "No messages yet" -newMessageExists: "There are new messages" onlyOneFileCanBeAttached: "You can only attach one file to a message" signinRequired: "Please register or sign in before continuing" signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." @@ -646,8 +644,8 @@ disablePlayer: "Close video player" expandTweet: "Expand post" themeEditor: "Theme editor" description: "Description" -describeFile: "Add caption" -enterFileDescription: "Enter caption" +describeFile: "Add alt text" +enterFileDescription: "Enter alt text" author: "Author" leaveConfirm: "There are unsaved changes. Do you want to discard them?" manage: "Management" @@ -1017,7 +1015,7 @@ sendPushNotificationReadMessageCaption: "This may increase the power consumption windowMaximize: "Maximize" windowMinimize: "Minimize" windowRestore: "Restore" -caption: "Caption" +caption: "Alt text" loggedInAsBot: "Currently logged in as bot" tools: "Tools" cannotLoad: "Unable to load" @@ -1317,7 +1315,7 @@ unmarkAsSensitiveConfirm: "Do you want to remove the sensitive designation for t preferences: "Preferences" accessibility: "Accessibility" preferencesProfile: "Preferences profile" -copyPreferenceId: "Copy the proference ID" +copyPreferenceId: "Copy the preference ID" resetToDefaultValue: "Revert to default" overrideByAccount: "Override by the account" untitled: "Untitled" @@ -1334,6 +1332,64 @@ paste: "Paste" emojiPalette: "Emoji palette" postForm: "Posting form" textCount: "Character count" +information: "About" +chat: "Chat" +migrateOldSettings: "Migrate old client settings" +migrateOldSettings_description: "This should be done automatically but if for some reason the migration was not successful, you can trigger the migration process yourself manually. The current configuration information will be overwritten." +compress: "Compress" +right: "Right" +bottom: "Bottom" +top: "Top" +embed: "Embed" +settingsMigrating: "Settings are being migrated, please wait a moment... (You can also migrate manually later by going to Settings→Others→Migrate old settings)" +readonly: "Read only" +goToDeck: "Return to Deck" +_chat: + noMessagesYet: "No messages yet" + newMessage: "New message" + individualChat: "Private Chat" + individualChat_description: "Have a private chat with another person." + roomChat: "Room Chat" + roomChat_description: "A chat room which can have multiple people.\nYou can also invite people who don't allow private chats if they accept the invite." + createRoom: "Create Room" + inviteUserToChat: "Invite users to start chatting" + yourRooms: "Created rooms" + joiningRooms: "Joined rooms" + invitations: "Invite" + noInvitations: "No invitations" + history: "History" + noHistory: "No history available" + noRooms: "No rooms found" + inviteUser: "Invite Users" + sentInvitations: "Sent Invites" + join: "Join" + ignore: "Ignore" + leave: "Leave room" + members: "Members" + searchMessages: "Search messages" + home: "Home" + send: "Send" + newline: "New line" + muteThisRoom: "Mute room" + deleteRoom: "Delete room" + chatNotAvailableForThisAccountOrServer: "Chat is not enabled on this server or for this account." + chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this instance or this account. You cannot write new messages or create/join chat rooms." + chatNotAvailableInOtherAccount: "The chat function is disabled for the other user." + cannotChatWithTheUser: "Cannot start a chat with this user" + cannotChatWithTheUser_description: "Chat is either unavailable or the other party has not enabled chat." + chatWithThisUser: "Chat with user" + thisUserAllowsChatOnlyFromFollowers: "This user accepts chats from followers only." + thisUserAllowsChatOnlyFromFollowing: "This user accepts chats only from users they follow." + thisUserAllowsChatOnlyFromMutualFollowing: "This user only accepts chats from users who are mutual followers." + thisUserNotAllowedChatAnyone: "This user is not accepting chats from anyone." + chatAllowedUsers: "Who to allow chatting with" + chatAllowedUsers_note: "You can chat with anyone to whom you have sent a chat message regardless of this setting." + _chatAllowedUsers: + everyone: "Everyone" + followers: "Only your followers" + following: "Only users you are following" + mutual: "Mutual followers only" + none: "Nobody" _emojiPalette: palettes: "Palette" enableSyncBetweenDevicesForPalettes: "Enable palette sync between devices" @@ -1359,6 +1415,14 @@ _settings: timelineAndNote: "Timeline and note" makeEveryTextElementsSelectable: "Make all text elements selectable" makeEveryTextElementsSelectable_description: "Enabling this may reduce usability in some situations." + useStickyIcons: "Make icons follow while scrolling" + showNavbarSubButtons: "Show sub-buttons on the navigation bar" + ifOn: "When turned on" + ifOff: "When turned off" + enableSyncThemesBetweenDevices: "Synchronize installed themes across devices" + _chat: + showSenderName: "Show sender's name" + sendOnEnter: "Press Enter to send" _preferencesProfile: profileName: "Profile name" profileNameDescription: "Set a name that identifies this device." @@ -1391,7 +1455,7 @@ _abuseUserReport: resolve: "Resolve" accept: "Accept" reject: "Reject" - resolveTutorial: "If the report is legitimate in content, select \"Accept\" to mark the case as resolved in the affirmative.\nIf the content of the report is not legitimate, select \"Reject\" to mark the case as resolved in the negative." + resolveTutorial: "If the report's content is legitimate, select \"Accept\" to mark it as resolved.\nIf the report's content is illegitimate, select \"Reject\" to ignore it." _delivery: status: "Delivery status" stop: "Suspended" @@ -1828,6 +1892,8 @@ _role: descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled." displayOrder: "Position" descriptionOfDisplayOrder: "The higher the number, the higher its UI position." + preserveAssignmentOnMoveAccount: "Preserve role assignment during migration" + preserveAssignmentOnMoveAccount_description: "When turned on, this role will be carried over to the destination account when an account with this role is migrated." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." priority: "Priority" @@ -1868,6 +1934,7 @@ _role: canImportFollowing: "Allow importing following" canImportMuting: "Allow importing muting" canImportUserLists: "Allow importing lists" + chatAvailability: "Allow Chat" _condition: roleAssignedTo: "Assigned to manual roles" isLocal: "Local user" @@ -2064,7 +2131,6 @@ _theme: header: "Header" navBg: "Sidebar background" navFg: "Sidebar text" - navHoverFg: "Sidebar text (Hover)" navActive: "Sidebar text (Active)" navIndicator: "Sidebar indicator" link: "Link" @@ -2087,17 +2153,15 @@ _theme: buttonHoverBg: "Button background (Hover)" inputBorder: "Input field border" driveFolderBg: "Drive folder background" - wallpaperOverlay: "Wallpaper overlay" badge: "Badge" messageBg: "Chat background" - accentDarken: "Accent (Darkened)" - accentLighten: "Accent (Lightened)" fgHighlighted: "Highlighted Text" _sfx: note: "New note" noteMy: "Own note" notification: "Notifications" reaction: "On choosing a reaction" + chatMessage: "Chat Messages" _soundSettings: driveFile: "Use an audio file in Drive." driveFileWarn: "Select an audio file from Drive." @@ -2244,6 +2308,8 @@ _permissions: "read:clip-favorite": "View favorited clips" "read:federation": "Get federation data" "write:report-abuse": "Report violation" + "write:chat": "Compose or delete chat messages" + "read:chat": "Browse Chat" _auth: shareAccessTitle: "Granting application permissions" shareAccess: "Would you like to authorize \"{name}\" to access this account?" @@ -2302,6 +2368,7 @@ _widgets: chooseList: "Select a list" clicker: "Clicker" birthdayFollowings: "Today's Birthdays" + chat: "Chat" _cw: hide: "Hide" show: "Show content" @@ -2492,6 +2559,7 @@ _notification: newNote: "New note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Role given" + chatRoomInvitationReceived: "You have been invited to a chat room" emptyPushNotificationMessage: "Push notifications have been updated" achievementEarned: "Achievement unlocked" testNotification: "Test notification" @@ -2520,6 +2588,7 @@ _notification: receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" + chatRoomInvitationReceived: "Invited to chat room" achievementEarned: "Achievement unlocked" exportCompleted: "The export has been completed" login: "Sign In" @@ -2533,6 +2602,9 @@ _notification: _deck: alwaysShowMainColumn: "Always show main column" columnAlign: "Align columns" + columnGap: "Margin between columns" + deckMenuPosition: "Deck menu position" + navbarPosition: "Navigation bar position" addColumn: "Add column" newNoteNotificationSettings: "Notification setting for new notes" configureColumn: "Column settings" @@ -2546,7 +2618,7 @@ _deck: newProfile: "New profile" deleteProfile: "Delete profile" introduction: "Create the perfect interface for you by arranging columns freely!" - introduction2: "Click on the + on the right of the screen to add new colums whenever you want." + introduction2: "Click on the + on the right of the screen to add new columns whenever you want." widgetsIntroduction: "Please select \"Edit widgets\" in the column menu and add a widget." useSimpleUiForNonRootPages: "Use simple UI for navigated pages" usedAsMinWidthWhenFlexible: "Minimum width will be used for this when the \"Auto-adjust width\" option is enabled" @@ -2563,6 +2635,7 @@ _deck: mentions: "Mentions" direct: "Direct notes" roleTimeline: "Role Timeline" + chat: "Chat" _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." @@ -2597,7 +2670,7 @@ _webhookSettings: testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data." _abuseReport: _notificationRecipient: - createRecipient: "Add a recipient for reports" + createRecipient: "Add recipient for reports" modifyRecipient: "Edit a recipient for reports" recipientType: "Notification type" _recipientType: @@ -2659,6 +2732,7 @@ _moderationLogTypes: deletePage: "Page deleted" deleteFlash: "Play deleted" deleteGalleryPost: "Gallery post deleted" + deleteChatRoom: "Deleted Chat Room" updateProxyAccountDescription: "Update the description of the proxy account" _fileViewer: title: "File details" @@ -2673,10 +2747,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Make sure the distributor of this resource is trustworthy before installation." _plugin: title: "Do you want to install this plugin?" - metaTitle: "Plugin information" _theme: title: "Do you want to install this theme?" - metaTitle: "Theme information" _meta: base: "Base color scheme" _vendorInfo: @@ -2829,7 +2901,7 @@ _customEmojisManager: confirmImportEmojisTitle: "Import Emojis" confirmImportEmojisDescription: "Import {count} Emoji(s) received from the remote server. Please pay close attention to the license of the Emoji. Are you sure to continue?" _local: - tabTitleList: "List of registered Emojis" + tabTitleList: "Registered emojis" tabTitleRegister: "Emoji registration" _list: emojisNothing: "There are no registered Emojis." @@ -2877,8 +2949,8 @@ _selfXssPrevention: description2: "If you do not understand exactly what you are trying to paste, %cstop working right now and close this window." description3: "For more information, please refer to this. {link}" _followRequest: - recieved: "Received application" - sent: "Sent application" + recieved: "Received request" + sent: "Sent request" _remoteLookupErrors: _federationNotAllowed: title: "Unable to communicate with this server" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 4933b41ddb..713478b67e 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "¿Desea borrar \"{x}\"?" resetAreYouSure: "¿Desea reestablecer?" areYouSure: "¿Estás conforme?" saved: "Guardado" -messaging: "Chat" upload: "Subir" keepOriginalUploading: "Mantener la imagen original" keepOriginalUploadingDescription: "Mantener la versión original al cargar imágenes. Si está desactivado, el navegador generará imágenes para la publicación web en el momento de recargar la página" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo." explore: "Explorar" messageRead: "Ya leído" noMoreHistory: "El historial se ha acabado" -startMessaging: "Iniciar chat" +startChat: "Nuevo Chat" nUsersRead: "Leído por {n} personas" agreeTo: "De acuerdo con {0}" agree: "De acuerdo." @@ -491,8 +490,6 @@ noteOf: "Notas de {user}" quoteAttached: "Cita añadida" quoteQuestion: "¿Quiere añadir una cita?" attachAsFileQuestion: "El texto del portapapeles es demasiado grande ¿Desea adjuntarlo como archivo de texto?" -noMessagesYet: "Aún no hay chat" -newMessageExists: "Tienes un mensaje nuevo" onlyOneFileCanBeAttached: "Solo se puede añadir un archivo al mensaje" signinRequired: "Iniciar sesión" signinOrContinueOnRemote: "Para continuar, tendrá que ir a su servidor o registrarse e iniciar sesión en este servidor" @@ -698,6 +695,7 @@ userSaysSomethingAbout: "{name} dijo algo sobre {word}" makeActive: "Activar" display: "Apariencia" copy: "Copiar" +copiedToClipboard: "Texto copiado al portapapeles" metrics: "Métricas" overview: "Resumen" logs: "Registros" @@ -1297,10 +1295,55 @@ passkeyVerificationFailed: "La verificación de la clave de acceso ha fallado." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificación de la clave de acceso ha sido satisfactoria pero se ha deshabilitado el inicio de sesión sin contraseña." messageToFollower: "Mensaje a seguidores" target: "Para" +prohibitedWordsForNameOfUser: "Palabras prohibidas para nombres de usuario" +prohibitedWordsForNameOfUserDescription: "Si alguna de las cadenas de esta lista está incluida en el nombre del usuario, el nombre será denegado. Los usuarios con privilegios de moderador no se ven afectados por esta restricción." +yourNameContainsProhibitedWords: "Tu nombre contiene palabras prohibidas" +yourNameContainsProhibitedWordsDescription: "Si deseas usar este nombre, por favor contacta con tu administrador/a de tu servidor" +lockdown: "Bloqueo" +pleaseSelectAccount: "Seleccione una cuenta, por favor." +availableRoles: "Roles disponibles " +acknowledgeNotesAndEnable: "Activar después de comprender las precauciones" federationSpecified: "Este servidor opera en una federación de listas blancas. No puede interactuar con otros servidores que no sean los especificados por el administrador." federationDisabled: "La federación está desactivada en este servidor. No puede interactuar con usuarios de otros servidores" +preferences: "Preferencias" postForm: "Formulario" +information: "Información" +right: "Derecha" +bottom: "Abajo" +top: "Arriba" +embed: "Insertar" +settingsMigrating: "La configuración está siendo migrada, por favor espera un momento... (También puedes migrar manualmente más tarde yendo a Ajustes otros migrar configuración antigua" +readonly: "Solo Lectura" +_chat: + noMessagesYet: "Aún no hay mensajes" + newMessage: "Mensajes nuevos" + individualChat: "Chat individual" + individualChat_description: "Mantén una conversación privada con otra persona." + invitations: "Invitar" + noHistory: "No hay datos en el historial" + members: "Miembros" + home: "Inicio" + send: "Enviar" + chatNotAvailableInOtherAccount: "La función de chat está desactivada para el otro usuario." + cannotChatWithTheUser: "No se puede iniciar un chat con este usuario" + cannotChatWithTheUser_description: "El chat no está disponible o la otra parte no ha habilitado el chat." + chatWithThisUser: "Chatear" + thisUserAllowsChatOnlyFromFollowers: "Este usuario sólo acepta chats de seguidores." + thisUserAllowsChatOnlyFromFollowing: "Este usuario sólo acepta chats de los usuarios a los que sigue." + thisUserAllowsChatOnlyFromMutualFollowing: "Este usuario sólo acepta chats de usuarios que son seguidores mutuos." + thisUserNotAllowedChatAnyone: "Este usuario no acepta chats de nadie." + chatAllowedUsers: "A quién permitir chatear." + chatAllowedUsers_note: "Puedes chatear con cualquier persona a la que hayas enviado un mensaje de chat, independientemente de esta configuración." + _chatAllowedUsers: + everyone: "Todos" + followers: "Sólo sus propios seguidores." + following: "Solo usuarios que sigues" + mutual: "Solo seguidores mutuos" + none: "Nadie" +_emojiPalette: + palettes: "Paleta\n" _settings: + api: "API" webhook: "Webhook" _accountSettings: requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido" @@ -1961,7 +2004,6 @@ _theme: header: "Cabezal" navBg: "Fondo de la barra lateral" navFg: "Texto de la barra lateral" - navHoverFg: "Texto de la barra lateral (hover)" navActive: "Texto de la barra lateral (activo)" navIndicator: "Indicador de la barra lateral" link: "Vínculo" @@ -1984,11 +2026,8 @@ _theme: buttonHoverBg: "Fondo de botón (hover)" inputBorder: "Borde de los campos de entrada" driveFolderBg: "Fondo de capeta del drive" - wallpaperOverlay: "Transparencia del fondo de pantalla" badge: "Medalla" messageBg: "Fondo de chat" - accentDarken: "Acento (oscuro)" - accentLighten: "Acento (claro)" fgHighlighted: "Texto resaltado" _sfx: note: "Notas" @@ -2140,6 +2179,7 @@ _permissions: "read:clip-favorite": "Ver los clips que me gustan" "read:federation": "Ver instancias federadas" "write:report-abuse": "Crear reportes de usuario" + "write:chat": "Administrar chat" _auth: shareAccessTitle: "Permisos de la aplicación" shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?" @@ -2521,10 +2561,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Asegúrate de que el distribuidor de este recurso es de confianza antes de proceder a la instalación." _plugin: title: "¿Quieres instalar este plugin?" - metaTitle: "Información del plugin" _theme: title: "¿Quieres instalar este tema?" - metaTitle: "Información del tema" _meta: base: "Esquema de color base" _vendorInfo: diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 6d3a3bb882..3a6f520ae6 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -277,7 +277,6 @@ deleteAreYouSure: "Êtes-vous sûr·e de vouloir supprimer « {x} » ?" resetAreYouSure: "Voulez-vous réinitialiser ?" areYouSure: "Êtes-vous sûr·e ?" saved: "Enregistré" -messaging: "Discuter" upload: "Téléverser" keepOriginalUploading: "Garder l’image d’origine" keepOriginalUploadingDescription: "Conserve la version originale lors du téléchargement d'images. S'il est désactivé, le navigateur génère l'image pour la publication web lors du téléchargement." @@ -290,7 +289,6 @@ uploadFromUrlMayTakeTime: "Le téléversement de votre fichier peut prendre un c explore: "Découvrir" messageRead: "Lu" noMoreHistory: "Il n’y a plus d’historique" -startMessaging: "Commencer à discuter" nUsersRead: "Lu par {n} personnes" agreeTo: "J’accepte {0}" agree: "Accepter" @@ -477,8 +475,6 @@ retype: "Confirmation" noteOf: "Notes de {user}" quoteAttached: "Avec citation" quoteQuestion: "Souhaitez-vous ajouter une citation ?" -noMessagesYet: "Pas encore de discussion" -newMessageExists: "Vous avez un nouveau message" onlyOneFileCanBeAttached: "Vous ne pouvez joindre qu’un seul fichier au message" signinRequired: "Veuillez vous connecter" invitations: "Invitations" @@ -1278,6 +1274,13 @@ lockdown: "Verrouiller" pleaseSelectAccount: "Sélectionner un compte" availableRoles: "Rôles disponibles" postForm: "Formulaire de publication" +information: "Informations" +_chat: + invitations: "Inviter" + noHistory: "Pas d'historique" + members: "Membres" + home: "Principal" + send: "Envoyer" _abuseUserReport: forward: "Transférer" forwardDescription: "Transférer le signalement vers une instance distante en tant qu'anonyme." @@ -1813,7 +1816,6 @@ _theme: header: "Entête" navBg: "Fond de la barre latérale" navFg: "Texte de la barre latérale" - navHoverFg: "Texte de la barre latérale (survolé)" navActive: "Texte de la barre latérale (actif)" navIndicator: "Indicateur de barre latérale" link: "Lien" @@ -1836,11 +1838,8 @@ _theme: buttonHoverBg: "Arrière-plan du bouton (survolé)" inputBorder: "Cadre de la zone de texte" driveFolderBg: "Arrière-plan du dossier de disque" - wallpaperOverlay: "Superposition de fond d'écran" badge: "Badge" messageBg: "Arrière plan de la discussion" - accentDarken: "Plus sombre" - accentLighten: "Plus clair" fgHighlighted: "Texte mis en évidence" _sfx: note: "Nouvelle note" @@ -1950,6 +1949,7 @@ _permissions: "write:admin:unsuspend-user": "Lever la suspension d'un utilisateur" "write:admin:meta": "Gérer les métadonnées de l'instance" "write:admin:roles": "Gérer les rôles" + "write:chat": "Gérer les discussions" _auth: shareAccess: "Autoriser \"{name}\" à accéder à votre compte ?" shareAccessAsk: "Voulez-vous vraiment autoriser cette application à accéder à votre compte?" @@ -2295,10 +2295,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Veuillez confirmer que le distributeur est fiable avant l'installation." _plugin: title: "Voulez-vous installer cette extension ?" - metaTitle: "Informations sur l'extension" _theme: title: "Voulez-vous installer ce thème ?" - metaTitle: "Informations sur le thème" _meta: base: "Palette de couleurs de base" _vendorInfo: diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 62ddc35cad..22ccbf153a 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -280,7 +280,6 @@ deleteAreYouSure: "Apakah kamu yakin ingin menghapus \"{x}\"?" resetAreYouSure: "Yakin mau atur ulang?" areYouSure: "Apakah kamu yakin?" saved: "Telah disimpan" -messaging: "Pesan" upload: "Unggah" keepOriginalUploading: "Simpan gambar asli" keepOriginalUploadingDescription: "Simpan gambar yang diunggah sebagaimana gambar aslinya. Bila dimatikan, versi tampilan web akan dihasilkan pada saat diunggah." @@ -293,7 +292,6 @@ uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesa explore: "Jelajahi" messageRead: "Telah dibaca" noMoreHistory: "Tidak ada sejarah lagi" -startMessaging: "Mulai mengirim pesan" nUsersRead: "Dibaca oleh {n}" agreeTo: "Saya setuju kepada {0}" agree: "Setuju" @@ -481,8 +479,6 @@ noteOf: "Catatan milik {user}" quoteAttached: "Dikutip" quoteQuestion: "Apakah kamu ingin menambahkan kutipan?" attachAsFileQuestion: "Teks dalam papan klip terlalu panjang. Apakah kamu ingin melampirkannya sebagai berkas teks?" -noMessagesYet: "Tidak ada pesan" -newMessageExists: "Kamu mendapatkan pesan baru" onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" signinRequired: "Silahkan login" invitations: "Undangan" @@ -1262,6 +1258,13 @@ modified: "Diubah" thereAreNChanges: "Ada {n} perubahan" prohibitedWordsForNameOfUser: "Kata yang dilarang untuk nama pengguna" postForm: "Buat catatan" +information: "Informasi" +_chat: + invitations: "Undang" + noHistory: "Tidak ada riwayat" + members: "Anggota" + home: "Beranda" + send: "Kirim" _settings: webhook: "Webhook" _abuseUserReport: @@ -1928,7 +1931,6 @@ _theme: header: "Header" navBg: "Latar belakang bilah samping" navFg: "Teks bilah samping" - navHoverFg: "Teks bilah samping (Mengambang)" navActive: "Teks bilah samping (Aktif)" navIndicator: "Indikator bilah samping" link: "Tautan" @@ -1951,11 +1953,8 @@ _theme: buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" driveFolderBg: "Latar belakang folder drive" - wallpaperOverlay: "Lapisan wallpaper" badge: "Lencana" messageBg: "Latar belakang obrolan" - accentDarken: "Aksen (Gelap)" - accentLighten: "Aksen (Terang)" fgHighlighted: "Teks yang disorot" _sfx: note: "Catatan" @@ -2108,6 +2107,7 @@ _permissions: "read:clip-favorite": "Lihat klip yang difavoritkan" "read:federation": "Mendapatkan data federasi" "write:report-abuse": "Melaporkan pelanggaran" + "write:chat": "Buat atau hapus obrolan" _auth: shareAccessTitle: "Mendapatkan ijin akses aplikasi" shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" @@ -2492,10 +2492,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Pastikan sumber dari sumber daya ini terpercaya sebelum melakukan pemasangan." _plugin: title: "Apakah kamu ingin memasang plugin ini?" - metaTitle: "Informasi plugin" _theme: title: "Apakah kamu ingin memasang tema ini?" - metaTitle: "Informasi tema" _meta: base: "Skema warna dasar" _vendorInfo: diff --git a/locales/index.d.ts b/locales/index.d.ts index 933f357d5f..75e617ec82 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1174,10 +1174,6 @@ export interface Locale extends ILocale { * 保存しました */ "saved": string; - /** - * チャット - */ - "messaging": string; /** * アップロード */ @@ -1227,9 +1223,9 @@ export interface Locale extends ILocale { */ "noMoreHistory": string; /** - * チャットを開始 + * チャットを始める */ - "startMessaging": string; + "startChat": string; /** * {n}人が読みました */ @@ -1718,6 +1714,10 @@ export interface Locale extends ILocale { * ファイルが添付されたノートのみ */ "withFileAntenna": string; + /** + * センシティブなチャンネルのノートを除外 + */ + "excludeNotesInSensitiveChannel": string; /** * ブラウザへのプッシュ通知を有効にする */ @@ -1982,14 +1982,6 @@ export interface Locale extends ILocale { * クリップボードのテキストが長いです。テキストファイルとして添付しますか? */ "attachAsFileQuestion": string; - /** - * まだチャットはありません - */ - "noMessagesYet": string; - /** - * 新しいメッセージがあります - */ - "newMessageExists": string; /** * メッセージに添付できるファイルはひとつです */ @@ -3942,6 +3934,10 @@ export interface Locale extends ILocale { * ログアウトしますか? */ "logoutConfirm": string; + /** + * ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。 + */ + "logoutWillClearClientData": string; /** * 最終利用日時 */ @@ -5354,6 +5350,239 @@ export interface Locale extends ILocale { * 文字数 */ "textCount": string; + /** + * 情報 + */ + "information": string; + /** + * チャット + */ + "chat": string; + /** + * 旧設定情報を移行 + */ + "migrateOldSettings": string; + /** + * 通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。 + */ + "migrateOldSettings_description": string; + /** + * 圧縮 + */ + "compress": string; + /** + * 右 + */ + "right": string; + /** + * 下 + */ + "bottom": string; + /** + * 上 + */ + "top": string; + /** + * 埋め込み + */ + "embed": string; + /** + * 設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます) + */ + "settingsMigrating": string; + /** + * 読み取り専用 + */ + "readonly": string; + /** + * デッキへ戻る + */ + "goToDeck": string; + /** + * 連合ジョブ + */ + "federationJobs": string; + "_chat": { + /** + * まだメッセージはありません + */ + "noMessagesYet": string; + /** + * 新しいメッセージ + */ + "newMessage": string; + /** + * 個人チャット + */ + "individualChat": string; + /** + * 特定ユーザーとの一対一のチャットができます。 + */ + "individualChat_description": string; + /** + * ルームチャット + */ + "roomChat": string; + /** + * 複数人でのチャットができます。 + * また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。 + */ + "roomChat_description": string; + /** + * ルームを作成 + */ + "createRoom": string; + /** + * ユーザーを招待してチャットを始めましょう + */ + "inviteUserToChat": string; + /** + * 作成したルーム + */ + "yourRooms": string; + /** + * 参加中のルーム + */ + "joiningRooms": string; + /** + * 招待 + */ + "invitations": string; + /** + * 招待はありません + */ + "noInvitations": string; + /** + * 履歴 + */ + "history": string; + /** + * 履歴はありません + */ + "noHistory": string; + /** + * ルームはありません + */ + "noRooms": string; + /** + * ユーザーを招待 + */ + "inviteUser": string; + /** + * 送信した招待 + */ + "sentInvitations": string; + /** + * 参加 + */ + "join": string; + /** + * 無視 + */ + "ignore": string; + /** + * ルームから退出 + */ + "leave": string; + /** + * メンバー + */ + "members": string; + /** + * メッセージを検索 + */ + "searchMessages": string; + /** + * ホーム + */ + "home": string; + /** + * 送信 + */ + "send": string; + /** + * 改行 + */ + "newline": string; + /** + * このルームをミュート + */ + "muteThisRoom": string; + /** + * ルームを削除 + */ + "deleteRoom": string; + /** + * このサーバー、またはこのアカウントでチャットは有効化されていません。 + */ + "chatNotAvailableForThisAccountOrServer": string; + /** + * このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。 + */ + "chatIsReadOnlyForThisAccountOrServer": string; + /** + * 相手のアカウントでチャット機能が使えない状態になっています。 + */ + "chatNotAvailableInOtherAccount": string; + /** + * このユーザーとのチャットを開始できません + */ + "cannotChatWithTheUser": string; + /** + * チャットが使えない状態になっているか、相手がチャットを開放していません。 + */ + "cannotChatWithTheUser_description": string; + /** + * チャットする + */ + "chatWithThisUser": string; + /** + * このユーザーはフォロワーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromFollowers": string; + /** + * このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromFollowing": string; + /** + * このユーザーは相互フォローのユーザーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromMutualFollowing": string; + /** + * このユーザーは誰からもチャットを受け付けていません。 + */ + "thisUserNotAllowedChatAnyone": string; + /** + * チャットを許可する相手 + */ + "chatAllowedUsers": string; + /** + * 自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。 + */ + "chatAllowedUsers_note": string; + "_chatAllowedUsers": { + /** + * 誰でも + */ + "everyone": string; + /** + * 自分のフォロワーのみ + */ + "followers": string; + /** + * 自分がフォローしているユーザーのみ + */ + "following": string; + /** + * 相互フォローのユーザーのみ + */ + "mutual": string; + /** + * 誰も許可しない + */ + "none": string; + }; + }; "_emojiPalette": { /** * パレット @@ -5449,6 +5678,36 @@ export interface Locale extends ILocale { * 有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。 */ "makeEveryTextElementsSelectable_description": string; + /** + * アイコンをスクロールに追従させる + */ + "useStickyIcons": string; + /** + * ナビゲーションバーに副ボタンを表示 + */ + "showNavbarSubButtons": string; + /** + * オンのとき + */ + "ifOn": string; + /** + * オフのとき + */ + "ifOff": string; + /** + * デバイス間でインストールしたテーマを同期 + */ + "enableSyncThemesBetweenDevices": string; + "_chat": { + /** + * 送信者の名前を表示 + */ + "showSenderName": string; + /** + * Enterで送信 + */ + "sendOnEnter": string; + }; }; "_preferencesProfile": { /** @@ -7126,6 +7385,14 @@ export interface Locale extends ILocale { * 数値が大きいほどUI上で先頭に表示されます。 */ "descriptionOfDisplayOrder": string; + /** + * アサイン状態を移行先アカウントにも引き継ぐ + */ + "preserveAssignmentOnMoveAccount": string; + /** + * オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。 + */ + "preserveAssignmentOnMoveAccount_description": string; /** * モデレーターのメンバー編集を許可 */ @@ -7281,6 +7548,10 @@ export interface Locale extends ILocale { * リストのインポートを許可 */ "canImportUserLists": string; + /** + * チャットを許可 + */ + "chatAvailability": string; }; "_condition": { /** @@ -8008,23 +8279,19 @@ export interface Locale extends ILocale { */ "header": string; /** - * サイドバーの背景 + * ナビゲーションバーの背景 */ "navBg": string; /** - * サイドバーの文字 + * ナビゲーションバーの文字 */ "navFg": string; /** - * サイドバー文字(ホバー) - */ - "navHoverFg": string; - /** - * サイドバー文字(アクティブ) + * ナビゲーションバー文字(アクティブ) */ "navActive": string; /** - * サイドバーのインジケーター + * ナビゲーションバーのインジケーター */ "navIndicator": string; /** @@ -8044,7 +8311,7 @@ export interface Locale extends ILocale { */ "mentionMe": string; /** - * Renote + * リノート */ "renote": string; /** @@ -8107,10 +8374,6 @@ export interface Locale extends ILocale { * ドライブフォルダーの背景 */ "driveFolderBg": string; - /** - * 壁紙のオーバーレイ - */ - "wallpaperOverlay": string; /** * バッジ */ @@ -8119,14 +8382,6 @@ export interface Locale extends ILocale { * チャットの背景 */ "messageBg": string; - /** - * アクセント (暗め) - */ - "accentDarken": string; - /** - * アクセント (明るめ) - */ - "accentLighten": string; /** * 強調された文字 */ @@ -8150,6 +8405,10 @@ export interface Locale extends ILocale { * リアクション選択時 */ "reaction": string; + /** + * チャットのメッセージ + */ + "chatMessage": string; }; "_soundSettings": { /** @@ -8722,6 +8981,14 @@ export interface Locale extends ILocale { * 違反を報告する */ "write:report-abuse": string; + /** + * チャットを操作する + */ + "write:chat": string; + /** + * チャットを閲覧する + */ + "read:chat": string; }; "_auth": { /** @@ -8944,6 +9211,10 @@ export interface Locale extends ILocale { * 今日誕生日のユーザー */ "birthdayFollowings": string; + /** + * チャット + */ + "chat": string; }; "_cw": { /** @@ -9672,6 +9943,10 @@ export interface Locale extends ILocale { * ロールが付与されました */ "roleAssigned": string; + /** + * チャットルームへ招待されました + */ + "chatRoomInvitationReceived": string; /** * プッシュ通知の更新をしました */ @@ -9781,6 +10056,10 @@ export interface Locale extends ILocale { * ロールが付与された */ "roleAssigned": string; + /** + * チャットルームへ招待された + */ + "chatRoomInvitationReceived": string; /** * 実績の獲得 */ @@ -9830,6 +10109,18 @@ export interface Locale extends ILocale { * カラムの寄せ */ "columnAlign": string; + /** + * カラム間のマージン + */ + "columnGap": string; + /** + * デッキメニューの位置 + */ + "deckMenuPosition": string; + /** + * ナビゲーションバーの位置 + */ + "navbarPosition": string; /** * カラムを追加 */ @@ -9883,7 +10174,7 @@ export interface Locale extends ILocale { */ "introduction": string; /** - * 画面の右にある + を押して、いつでもカラムを追加できます。 + * カラムを追加するには、画面の + をクリックします。 */ "introduction2": string; /** @@ -9947,6 +10238,10 @@ export interface Locale extends ILocale { * ロールタイムライン */ "roleTimeline": string; + /** + * チャット + */ + "chat": string; }; }; "_dialog": { @@ -10310,6 +10605,10 @@ export interface Locale extends ILocale { * ギャラリーの投稿を削除 */ "deleteGalleryPost": string; + /** + * チャットルームを削除 + */ + "deleteChatRoom": string; /** * プロキシアカウントの説明を更新 */ @@ -10359,20 +10658,12 @@ export interface Locale extends ILocale { * このプラグインをインストールしますか? */ "title": string; - /** - * プラグイン情報 - */ - "metaTitle": string; }; "_theme": { /** * このテーマをインストールしますか? */ "title": string; - /** - * テーマ情報 - */ - "metaTitle": string; }; "_meta": { /** diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 7a39a8c0e6..75691d817f 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "Vuoi davvero eliminare \"{x}\"?" resetAreYouSure: "Ripristinare?" areYouSure: "Confermi?" saved: "Salvato" -messaging: "Messaggi" upload: "Carica" keepOriginalUploading: "Conservare l'immagine originale." keepOriginalUploadingDescription: "Conserva la versione originale quando si caricano le immagini. Se è disattivato, il browser genera l'immagine per la pubblicazione sul Web durante il caricamento." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "Il caricamento del file può richiedere tempo." explore: "Esplora" messageRead: "Visualizzato" noMoreHistory: "Non c'è più cronologia da visualizzare" -startMessaging: "Nuovo messaggio" +startChat: "Inizia a chattare" nUsersRead: "Letto da {n} persone" agreeTo: "Sono d'accordo con {0}" agree: "Accetto" @@ -425,6 +424,7 @@ antennaExcludeBots: "Escludere i Bot" antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)." notifyAntenna: "Invia notifiche delle nuove note" withFileAntenna: "Solo note con file in allegato" +excludeNotesInSensitiveChannel: "Escludere le Note dai canali espliciti" enableServiceworker: "Abilita ServiceWorker" antennaUsersDescription: "Elenca un nome utente per riga" caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole" @@ -491,8 +491,6 @@ noteOf: "Note di {user}" quoteAttached: "Citazione allegata" quoteQuestion: "Vuoi aggiungere una citazione?" attachAsFileQuestion: "Il testo copiato eccede le dimensioni, vuoi allegarlo?" -noMessagesYet: "Ancora nessuna chat" -newMessageExists: "Hai ricevuto un nuovo messaggio" onlyOneFileCanBeAttached: "È possibile allegare al messaggio soltanto uno file" signinRequired: "Occorre avere un profilo registrato su questa istanza" signinOrContinueOnRemote: "Per continuare, devi accedere alla tua istanza o registrarti su questa e poi accedere" @@ -525,7 +523,7 @@ showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mou showReactionsCount: "Visualizza il numero di reazioni su una nota" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" -enableAdvancedMfm: "Attiva MFM avanzati" +enableAdvancedMfm: "Attivare i Misskey Flavoured Markdown (MFM) avanzati" enableAnimatedMfm: "Attiva MFM animati" doing: "In corso..." category: "Categoria" @@ -608,7 +606,7 @@ uiInspector: "UI Inspector" uiInspectorDescription: "Puoi visualizzare un elenco di elementi UI presenti in memoria. I componenti dell'interfaccia utente vengono generati dalle funzioni Ui:C:." output: "Output" script: "Script" -disablePagesScript: "Disabilita AiScript nelle pagine" +disablePagesScript: "Disabilitare AiScript nelle pagine" updateRemoteUser: "Aggiorna dati dal profilo remoto" unsetUserAvatar: "Rimozione foto profilo" unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?" @@ -666,7 +664,7 @@ generateAccessToken: "Genera token di accesso" permission: "Autorizzazioni " adminPermission: "Privilegi amministrativi" enableAll: "Abilita tutto" -disableAll: "Disabilita tutto" +disableAll: "Disabilitare tutto" tokenRequested: "Autorizza accesso al profilo" pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." notificationType: "Tipo di notifiche" @@ -730,7 +728,7 @@ reporterOrigin: "Segnalazione da" send: "Inviare" openInNewTab: "Apri in una nuova scheda" openInSideView: "Apri in vista laterale" -defaultNavigationBehaviour: "Navigazione preimpostata" +defaultNavigationBehaviour: "Tipo di navigazione predefinita" editTheseSettingsMayBreakAccount: "Modificare queste impostazioni può danneggiare il profilo" instanceTicker: "Informazioni sull'istanza da cui vengono le note" waitingFor: "Aspettando {x}" @@ -769,7 +767,7 @@ noCrawleDescription: "Richiedi che i motori di ricerca non indicizzino la tua pa lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"Solo ai follower\", le tue note sono visibili da tutti, anche se hai configurato l'account per confermare manualmente le richieste di follow." alwaysMarkSensitive: "Segnare automaticamente come espliciti gli allegati" loadRawImages: "Visualizza le intere immagini allegate invece delle miniature." -disableShowingAnimatedImages: "Disabilita le immagini animate" +disableShowingAnimatedImages: "Disabilitare le immagini animate" 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" @@ -869,7 +867,7 @@ noBotProtectionWarning: "Non è stata impostata alcuna protezione dai Bot" configure: "Imposta" postToGallery: "Pubblicare nella galleria" postToHashtag: "Pubblica a questo hashtag" -gallery: "Galleria" +gallery: "Gallerie" recentPosts: "Pubblicazioni recenti" popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" @@ -981,6 +979,7 @@ document: "Documentazione" numberOfPageCache: "Numero di pagine cache" numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria." logoutConfirm: "Vuoi davvero uscire da Misskey? " +logoutWillClearClientData: "All'uscita, la configurazione del client viene rimossa dal browser. Per ripristinarla quando si effettua nuovamente l'accesso, abilitare il backup automatico." lastActiveDate: "Data dell'ultimo utilizzo" statusbar: "Barra di stato" pleaseSelect: "Scegli un'opzione" @@ -1195,7 +1194,7 @@ renotes: "Rinota" loadReplies: "Leggi le risposte" loadConversation: "Leggi la conversazione" pinnedList: "Elenco in primo piano" -keepScreenOn: "Mantieni lo schermo acceso" +keepScreenOn: "Mantenere lo schermo acceso" verifiedLink: "Abbiamo confermato la validità di questo collegamento" notifyNotes: "Notifica nuove Note" unnotifyNotes: "Interrompi le notifiche di nuove Note" @@ -1237,7 +1236,7 @@ flip: "Inverti" showAvatarDecorations: "Mostra decorazione della foto profilo" releaseToRefresh: "Rilascia per aggiornare" refreshing: "Aggiornamento..." -pullDownToRefresh: "Trascina per aggiornare" +pullDownToRefresh: "Trascinare per aggiornare" disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale" useGroupedNotifications: "Mostra le notifiche raggruppate" signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo." @@ -1265,7 +1264,7 @@ backToTitle: "Torna al titolo" hemisphere: "Geolocalizzazione" withSensitive: "Mostra le Note con allegati espliciti" userSaysSomethingSensitive: "Note da {name} con allegati espliciti" -enableHorizontalSwipe: "Trascina per invertire i tab" +enableHorizontalSwipe: "Trascinare per invertire le colonne" loading: "Caricamento" surrender: "Annulla" gameRetry: "Riprova" @@ -1334,6 +1333,64 @@ paste: "Incolla" emojiPalette: "Tavolozza emoji" postForm: "Finestra di pubblicazione" textCount: "Il numero di caratteri" +information: "Informazioni" +chat: "Chat" +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." +compress: "Comprimi" +right: "Destra" +bottom: "Sotto" +top: "Sopra" +embed: "Incorporare" +settingsMigrating: "Migrazione delle impostazioni. Attendere prego ... (Puoi anche migrare manualmente in un secondo momento, nel menu: Impostazioni → Altro → Migrazione delle impostazioni)" +readonly: "Sola lettura" +goToDeck: "Torna al Deck" +_chat: + noMessagesYet: "Ancora nessun messaggio" + newMessage: "Nuovo messaggio" + individualChat: "Chat individuale" + individualChat_description: "Puoi chattare con una persona specifica." + roomChat: "Stanza di chat" + roomChat_description: "Puoi chattare con più persone.\nInoltre, anche le persone che non consentono chat personalizzate possono chattare se gli altri accettano." + createRoom: "Crea stanza" + inviteUserToChat: "Invita a chattare altre persone" + yourRooms: "Le tue stanze" + joiningRooms: "Stanze a cui partecipi" + invitations: "Invita" + noInvitations: "Nessun invito" + history: "Cronologia" + noHistory: "Nessuna cronologia" + noRooms: "Nessuna stanza" + inviteUser: "Invita" + sentInvitations: "Inviti spediti" + join: "Entra" + ignore: "Ignora" + leave: "Esci" + members: "Membri" + searchMessages: "Cerca messaggi" + home: "Home" + send: "Inviare" + newline: "Nuova riga" + muteThisRoom: "Silenzia stanza" + deleteRoom: "Elimina stanza" + chatNotAvailableForThisAccountOrServer: "Questo server, o questo profilo ha disabilitato la chat." + chatIsReadOnlyForThisAccountOrServer: "Le chat, su questo server o su questo profilo, sono di sola lettura. Impossibile scrivere in chat o creare e partecipare a stanze." + chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona." + cannotChatWithTheUser: "Impossibile chattare con questa persona" + cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo." + chatWithThisUser: "Chatta con questa persona" + thisUserAllowsChatOnlyFromFollowers: "Questa persona permette di chattare soltanto i propri Follower." + thisUserAllowsChatOnlyFromFollowing: "Questa persona permette di chattare soltanto ai suoi Follow." + thisUserAllowsChatOnlyFromMutualFollowing: "Questa persona permette di chattare solo a relazioni reciproche." + thisUserNotAllowedChatAnyone: "Questa persona non permette di chattare a nessuno." + chatAllowedUsers: "Persone ammesse alla chat" + chatAllowedUsers_note: "Puoi chattare con le persone a cui hai già inviato un messaggio, indipendentemente da questa impostazione." + _chatAllowedUsers: + everyone: "Chiunque" + followers: "Solo i tuoi Follower" + following: "Solo i tuoi Follow" + mutual: "Solo relazioni reciproche" + none: "Nessuno" _emojiPalette: palettes: "Tavolozza" enableSyncBetweenDevicesForPalettes: "Attiva la sincronizzazione tra dispositivi" @@ -1359,6 +1416,14 @@ _settings: timelineAndNote: "Note e Timeline" makeEveryTextElementsSelectable: "Imposta ogni elemento come selezionabile" makeEveryTextElementsSelectable_description: "Potrebbe ridurre l'usabilità in alcune situazioni." + useStickyIcons: "Fissa le icone durante lo scorrimento" + showNavbarSubButtons: "Mostra i pulsanti secondari nella barra di navigazione" + ifOn: "Quando attivato" + ifOff: "Quando disattivato" + enableSyncThemesBetweenDevices: "Sincronizzare il tema tra i dispositivi" + _chat: + showSenderName: "Mostra il nome del mittente" + sendOnEnter: "Invio spedisce" _preferencesProfile: profileName: "Nome del profilo" profileNameDescription: "Impostare il nome che indentifica questo dispositivo." @@ -1828,6 +1893,8 @@ _role: descriptionOfIsExplorable: "Selezionandolo, la timeline del ruolo diventerà accessibile pubblicamente. Tranne se il ruolo non è pubblico." displayOrder: "Ordine di visualizzazione" descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" + preserveAssignmentOnMoveAccount: "Mantenere l'assegnazione alla migrazione del profilo" + preserveAssignmentOnMoveAccount_description: "Attivando, il ruolo verrà portato sul profilo destinatario, durante la migrazione." canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" descriptionOfCanEditMembersByModerator: "Se disattivo, potranno farlo solamente gli Amministratori." priority: "Priorità" @@ -1868,6 +1935,7 @@ _role: canImportFollowing: "Può importare Following" canImportMuting: "Può importare Silenziati" canImportUserLists: "Può importare liste di Profili" + chatAvailability: "Chat consentita" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" @@ -2064,14 +2132,13 @@ _theme: header: "Intestazione" navBg: "Sfondo della barra laterale" navFg: "Testo della barra laterale" - navHoverFg: "Testo della barra laterale (al passaggio del mouse)" navActive: "Testo della barra laterale (attivo)" navIndicator: "Indicatore di barra laterale" link: "Link" hashtag: "Hashtag" mention: "Menzioni" mentionMe: "Menzioni (di me)" - renote: "Rinota" + renote: "Renota" modalBg: "Sfondo modale." divider: "Interruzione di linea" scrollbarHandle: "Maniglie della barra di scorrimento" @@ -2087,17 +2154,15 @@ _theme: buttonHoverBg: "Sfondo del pulsante (sorvolato)" inputBorder: "Inquadra casella di testo" driveFolderBg: "Sfondo della cartella di disco" - wallpaperOverlay: "Sovrapposizione dello sfondo" badge: "Distintivo" messageBg: "Sfondo della chat" - accentDarken: "Temi (scuri)" - accentLighten: "Temi (luminosi)" fgHighlighted: "Testo in evidenza." _sfx: note: "Nota" noteMy: "Mia nota" notification: "Notifiche" reaction: "Quando seleziono una reazione" + chatMessage: "Messaggio di chat" _soundSettings: driveFile: "Suoni del Drive" driveFileWarn: "Seleziona file dal dispositivo" @@ -2244,6 +2309,8 @@ _permissions: "read:clip-favorite": "Vedere Clip preferite" "read:federation": "Vedere la federazione" "write:report-abuse": "Inviare segnalazioni" + "write:chat": "Gestire la chat" + "read:chat": "Visualizzare le chat" _auth: shareAccessTitle: "Permessi dell'applicazione" shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?" @@ -2302,6 +2369,7 @@ _widgets: chooseList: "Seleziona una lista" clicker: "Cliccheria" birthdayFollowings: "Compleanni del giorno" + chat: "Chat" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2492,6 +2560,7 @@ _notification: newNote: "Nuove Note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Ruolo assegnato" + chatRoomInvitationReceived: "Invito in una stanza di chat" emptyPushNotificationMessage: "Le notifiche push sono state aggiornate." achievementEarned: "Obiettivo raggiunto" testNotification: "Provare la notifica" @@ -2520,6 +2589,7 @@ _notification: receiveFollowRequest: "Richieste di follow in arrivo" followRequestAccepted: "Richieste di follow accettate" roleAssigned: "Ruolo concesso" + chatRoomInvitationReceived: "Invito in una stanza di chat" achievementEarned: "Risultato raggiunto" exportCompleted: "Esportazione completata" login: "Accessi" @@ -2532,7 +2602,10 @@ _notification: renote: "Rinota" _deck: alwaysShowMainColumn: "Mostra sempre la colonna principale" - columnAlign: "Allineare colonne" + columnAlign: "Allineamento delle colonne" + columnGap: "Spessore del margine tra colonne" + deckMenuPosition: "Posizione del menu Deck" + navbarPosition: "Posizione barra di navigazione" addColumn: "Aggiungi colonna" newNoteNotificationSettings: "Preferenze per le notifiche di nuove Note" configureColumn: "Impostazioni colonna" @@ -2559,10 +2632,11 @@ _deck: tl: "Timeline" antenna: "Antenne" list: "Liste" - channel: "Canale" + channel: "Canali" mentions: "Menzioni" direct: "Note Dirette" roleTimeline: "Timeline Ruolo" + chat: "Chat" _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({current})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({current})" @@ -2659,6 +2733,7 @@ _moderationLogTypes: deletePage: "Pagina eliminata" deleteFlash: "Play eliminato" deleteGalleryPost: "Eliminazione pubblicazione nella Galleria" + deleteChatRoom: "Elimina chat" updateProxyAccountDescription: "Aggiornata la descrizione del profilo proxy" _fileViewer: title: "Dettagli del file" @@ -2673,10 +2748,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Prima di installare, assicurati che la fonte sia affidabile." _plugin: title: "Vuoi davvero installare questo componente aggiuntivo?" - metaTitle: "Informazioni sul componente aggiuntivo" _theme: title: "Vuoi davvero installare questa variazione grafica?" - metaTitle: "Informazioni sulla variazione grafica" _meta: base: "Combinazione base di colori" _vendorInfo: @@ -2850,7 +2923,7 @@ _customEmojisManager: directoryToCategoryLabel: "Inseriscile in una cartella omonima alla categoria" directoryToCategoryCaption: "Crea il campo categoria in base alla cartella." emojiInputAreaCaption: "Seleziona l'emoji da registrare utilizzando uno dei metodi." - emojiInputAreaList1: "Trascina una immagine o una cartella in quest'area" + emojiInputAreaList1: "Trascinare una immagine o una cartella in quest'area" emojiInputAreaList2: "Clicca per scegliere file dal tuo dispositivo" emojiInputAreaList3: "Clicca per selezionare dal Drive" confirmRegisterEmojisDescription: "Registrazione delle emoji elencate come nuove emoji personalizzate. Vuoi davvero procedere? (Per evitare sovraccarichi, puoi registrare al massimo {count} emoji per volta)" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3dc2042686..05acbc5c7f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "「{x}」を削除しますか?" resetAreYouSure: "リセットしますか?" areYouSure: "よろしいですか?" saved: "保存しました" -messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像を保持" keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか explore: "みつける" messageRead: "既読" noMoreHistory: "これより過去の履歴はありません" -startMessaging: "チャットを開始" +startChat: "チャットを始める" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" agree: "同意する" @@ -425,6 +424,7 @@ antennaExcludeBots: "Botアカウントを除外" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" notifyAntenna: "新しいノートを通知する" withFileAntenna: "ファイルが添付されたノートのみ" +excludeNotesInSensitiveChannel: "センシティブなチャンネルのノートを除外" enableServiceworker: "ブラウザへのプッシュ通知を有効にする" antennaUsersDescription: "ユーザー名を改行で区切って指定します" caseSensitive: "大文字小文字を区別する" @@ -491,8 +491,6 @@ noteOf: "{user}のノート" quoteAttached: "引用付き" quoteQuestion: "引用として添付しますか?" attachAsFileQuestion: "クリップボードのテキストが長いです。テキストファイルとして添付しますか?" -noMessagesYet: "まだチャットはありません" -newMessageExists: "新しいメッセージがあります" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" signinRequired: "続行する前に、登録またはログインが必要です" signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります" @@ -981,6 +979,7 @@ document: "ドキュメント" numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" logoutConfirm: "ログアウトしますか?" +logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。" lastActiveDate: "最終利用日時" statusbar: "ステータスバー" pleaseSelect: "選択してください" @@ -1334,6 +1333,66 @@ paste: "ペースト" emojiPalette: "絵文字パレット" postForm: "投稿フォーム" textCount: "文字数" +information: "情報" +chat: "チャット" +migrateOldSettings: "旧設定情報を移行" +migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。" +compress: "圧縮" +right: "右" +bottom: "下" +top: "上" +embed: "埋め込み" +settingsMigrating: "設定を移行しています。しばらくお待ちください... (後ほど、設定→その他→旧設定情報を移行 で手動で移行することもできます)" +readonly: "読み取り専用" +goToDeck: "デッキへ戻る" +federationJobs: "連合ジョブ" + +_chat: + noMessagesYet: "まだメッセージはありません" + newMessage: "新しいメッセージ" + individualChat: "個人チャット" + individualChat_description: "特定ユーザーとの一対一のチャットができます。" + roomChat: "ルームチャット" + roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。" + createRoom: "ルームを作成" + inviteUserToChat: "ユーザーを招待してチャットを始めましょう" + yourRooms: "作成したルーム" + joiningRooms: "参加中のルーム" + invitations: "招待" + noInvitations: "招待はありません" + history: "履歴" + noHistory: "履歴はありません" + noRooms: "ルームはありません" + inviteUser: "ユーザーを招待" + sentInvitations: "送信した招待" + join: "参加" + ignore: "無視" + leave: "ルームから退出" + members: "メンバー" + searchMessages: "メッセージを検索" + home: "ホーム" + send: "送信" + newline: "改行" + muteThisRoom: "このルームをミュート" + deleteRoom: "ルームを削除" + chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" + chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。" + chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" + cannotChatWithTheUser: "このユーザーとのチャットを開始できません" + cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" + chatWithThisUser: "チャットする" + thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。" + thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。" + thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。" + thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。" + chatAllowedUsers: "チャットを許可する相手" + chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。" + _chatAllowedUsers: + everyone: "誰でも" + followers: "自分のフォロワーのみ" + following: "自分がフォローしているユーザーのみ" + mutual: "相互フォローのユーザーのみ" + none: "誰も許可しない" _emojiPalette: palettes: "パレット" @@ -1361,6 +1420,15 @@ _settings: timelineAndNote: "タイムラインとノート" makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする" makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。" + useStickyIcons: "アイコンをスクロールに追従させる" + showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示" + ifOn: "オンのとき" + ifOff: "オフのとき" + enableSyncThemesBetweenDevices: "デバイス間でインストールしたテーマを同期" + + _chat: + showSenderName: "送信者の名前を表示" + sendOnEnter: "Enterで送信" _preferencesProfile: profileName: "プロファイル名" @@ -1845,6 +1913,8 @@ _role: descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。" displayOrder: "表示順" descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" + preserveAssignmentOnMoveAccount: "アサイン状態を移行先アカウントにも引き継ぐ" + preserveAssignmentOnMoveAccount_description: "オンにすると、このロールが付与されたアカウントが移行された際に、移行先アカウントにもこのロールが引き継がれるようになります。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1885,6 +1955,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" + chatAvailability: "チャットを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -2101,16 +2172,15 @@ _theme: panel: "パネル" shadow: "影" header: "ヘッダー" - navBg: "サイドバーの背景" - navFg: "サイドバーの文字" - navHoverFg: "サイドバー文字(ホバー)" - navActive: "サイドバー文字(アクティブ)" - navIndicator: "サイドバーのインジケーター" + navBg: "ナビゲーションバーの背景" + navFg: "ナビゲーションバーの文字" + navActive: "ナビゲーションバー文字(アクティブ)" + navIndicator: "ナビゲーションバーのインジケーター" link: "リンク" hashtag: "ハッシュタグ" mention: "メンション" mentionMe: "あなた宛てメンション" - renote: "Renote" + renote: "リノート" modalBg: "モーダルの背景" divider: "分割線" scrollbarHandle: "スクロールバーの取っ手" @@ -2126,11 +2196,8 @@ _theme: buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" driveFolderBg: "ドライブフォルダーの背景" - wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" messageBg: "チャットの背景" - accentDarken: "アクセント (暗め)" - accentLighten: "アクセント (明るめ)" fgHighlighted: "強調された文字" _sfx: @@ -2138,6 +2205,7 @@ _sfx: noteMy: "ノート(自分)" notification: "通知" reaction: "リアクション選択時" + chatMessage: "チャットのメッセージ" _soundSettings: driveFile: "ドライブの音声を使用" @@ -2290,6 +2358,8 @@ _permissions: "read:clip-favorite": "クリップのいいねを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" + "write:chat": "チャットを操作する" + "read:chat": "チャットを閲覧する" _auth: shareAccessTitle: "アプリへのアクセス許可" @@ -2352,6 +2422,7 @@ _widgets: chooseList: "リストを選択" clicker: "クリッカー" birthdayFollowings: "今日誕生日のユーザー" + chat: "チャット" _cw: hide: "隠す" @@ -2556,6 +2627,7 @@ _notification: newNote: "新しい投稿" unreadAntennaNote: "アンテナ {name}" roleAssigned: "ロールが付与されました" + chatRoomInvitationReceived: "チャットルームへ招待されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" testNotification: "通知テスト" @@ -2585,6 +2657,7 @@ _notification: receiveFollowRequest: "フォロー申請を受け取った" followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" + chatRoomInvitationReceived: "チャットルームへ招待された" achievementEarned: "実績の獲得" exportCompleted: "エクスポートが完了した" login: "ログイン" @@ -2600,6 +2673,9 @@ _notification: _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" + columnGap: "カラム間のマージン" + deckMenuPosition: "デッキメニューの位置" + navbarPosition: "ナビゲーションバーの位置" addColumn: "カラムを追加" newNoteNotificationSettings: "新着ノート通知の設定" configureColumn: "カラムの設定" @@ -2613,7 +2689,7 @@ _deck: newProfile: "新規プロファイル" deleteProfile: "プロファイルを削除" introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" - introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" + introduction2: "カラムを追加するには、画面の + をクリックします。" widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください" useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示" usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります" @@ -2631,6 +2707,7 @@ _deck: mentions: "あなた宛て" direct: "ダイレクト" roleTimeline: "ロールタイムライン" + chat: "チャット" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" @@ -2733,6 +2810,7 @@ _moderationLogTypes: deletePage: "ページを削除" deleteFlash: "Playを削除" deleteGalleryPost: "ギャラリーの投稿を削除" + deleteChatRoom: "チャットルームを削除" updateProxyAccountDescription: "プロキシアカウントの説明を更新" _fileViewer: @@ -2749,10 +2827,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "配布元が信頼できるかを確認した上でインストールしてください。" _plugin: title: "このプラグインをインストールしますか?" - metaTitle: "プラグイン情報" _theme: title: "このテーマをインストールしますか?" - metaTitle: "テーマ情報" _meta: base: "基本のカラースキーム" _vendorInfo: diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index ae45379086..378eaf2ad5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "「{x}」はほかしてええか?" resetAreYouSure: "リセットしてええん?" areYouSure: "いいん?" saved: "保存したで!" -messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像のまんま" keepOriginalUploadingDescription: "画像を上げるときにオリジナル版のまんまにするで。オフにしたら、上げたときにブラウザでWeb公開用の画像を生成するで。 " @@ -302,7 +301,6 @@ uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間か explore: "みつける" messageRead: "もう読んだ" noMoreHistory: "これより昔のんはあらへんで" -startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" agree: "せやな" @@ -491,8 +489,6 @@ noteOf: "{user}はんのノート" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" attachAsFileQuestion: "クリップボードのテキストが長すぎるからテキストファイルとして添付してもええか?" -noMessagesYet: "まだチャットはあらへんで" -newMessageExists: "新しいメッセージがきたで" onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があるで" @@ -1312,6 +1308,13 @@ federationDisabled: "このサーバーは連合が無効化されてるで。 confirmOnReact: "ツッコむときに確認とる" reactAreYouSure: "\" {emoji} \" でツッコむ?" postForm: "投稿フォーム" +information: "情報" +_chat: + invitations: "来てや" + noHistory: "履歴はないわ。" + members: "メンバーはん" + home: "ホーム" + send: "送信" _settings: webhook: "Webhook" _accountSettings: @@ -2004,7 +2007,6 @@ _theme: header: "ヘッダー" navBg: "サイドバーの背景" navFg: "サイドバーの文字" - navHoverFg: "サイドバー文字(ホバー)" navActive: "サイドバー文字(アクティブ)" navIndicator: "サイドバーのインジケーター" link: "リンク" @@ -2027,11 +2029,8 @@ _theme: buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" driveFolderBg: "ドライブフォルダーの背景" - wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" messageBg: "チャットの背景" - accentDarken: "アクセント (暗め)" - accentLighten: "アクセント (明るめ)" fgHighlighted: "強調されとる文字" _sfx: note: "ノート" @@ -2184,6 +2183,7 @@ _permissions: "read:clip-favorite": "クリップのいいね見る" "read:federation": "連合の情報取得" "write:report-abuse": "違反報告" + "write:chat": "チャットを操作するで" _auth: shareAccessTitle: "アプリへのアクセス許してやったらどうや" shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?" @@ -2610,10 +2610,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "配ってるとこが信頼できるか確認した上でインストールしてな。" _plugin: title: "このプラグイン、インストールする?" - metaTitle: "プラグイン情報" _theme: title: "このテーマインストールする?" - metaTitle: "テーマ情報" _meta: base: "" _vendorInfo: diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index ce83ef0e07..fb21b47fac 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -263,7 +263,6 @@ deleteAreYouSure: "‘{x}’(얼)럴 뭉캡니꺼?" resetAreYouSure: "아시로 데돌립니꺼?" areYouSure: "갠찮십니꺼?" saved: "저장햇십니다" -messaging: "대화" upload: "올리기" keepOriginalUploading: "온본 두기" keepOriginalUploadingDescription: "이미지럴 올릴 때 온본얼 고대로 둡니다. 꺼모 올릴 때 브라우저서 웹 공개 이미지럴 맨겁니다." @@ -276,7 +275,6 @@ uploadFromUrlMayTakeTime: "올리기가 껕날라먼 시간이 쪼매 걸릴 깁 explore: "살펴보기" messageRead: "이럿어예" noMoreHistory: "요카마 옛날 기록이 어ᇝ십니다" -startMessaging: "대화하기" nUsersRead: "{n}멩이 이럿십니다" agreeTo: "{0}에 동이하기" agree: "동이합니다" @@ -457,8 +455,6 @@ retype: "다시 서기" noteOf: "{user}님으 노트" quoteAttached: "따옴" quoteQuestion: "따와가 작성하겠십니까?" -noMessagesYet: "아직 대화가 없십니다" -newMessageExists: "새 메시지가 있십니다" onlyOneFileCanBeAttached: "메시지엔 파일 하나까제밖에 몬 넣십니다" invitations: "초대하기" invitationCode: "초대장" @@ -655,6 +651,12 @@ replies: "답하기" renotes: "리노트" attach: "옇기" surrender: "아이예" +information: "정보" +_chat: + invitations: "초대하기" + noHistory: "기록이 없십니다" + members: "구성원" + home: "덜머리" _delivery: stop: "고만 보내예" _type: @@ -745,6 +747,7 @@ _theme: description: "설멩" keys: mention: "멘션" + renote: "리노트" _sfx: note: "새 노트" notification: "알림" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 798c371bc2..fb7db80186 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -64,8 +64,8 @@ copyNoteId: "노트 ID 복사" copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" -searchUser: "사용자 검색" -searchThisUsersNotes: "사용자의 노트 검색" +searchUser: "유저 검색" +searchThisUsersNotes: "유저의 노트를 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -267,7 +267,7 @@ publishing: "배포 중" notResponding: "응답 없음" instanceFollowing: "서버의 팔로잉" instanceFollowers: "서버의 팔로워" -instanceUsers: "서버의 사용자" +instanceUsers: "서버의 유저" changePassword: "비밀번호 변경" security: "보안" retypedNotMatch: "입력이 일치하지 않습니다." @@ -289,7 +289,6 @@ deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" resetAreYouSure: "초기화 하시겠습니까?" areYouSure: "계속 진행하시겠습니까?" saved: "저장했습니다" -messaging: "대화" upload: "업로드" keepOriginalUploading: "원본 이미지를 유지" keepOriginalUploadingDescription: "이미지를 업로드할 때에 원본을 그대로 유지합니다. 비활성화하면 업로드할 때 브라우저에서 웹 공개용 이미지를 생성합니다." @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 explore: "둘러보기" messageRead: "읽음" noMoreHistory: "이것보다 과거의 기록이 없습니다" -startMessaging: "대화 시작하기" +startChat: "채팅을 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" agree: "동의합니다" @@ -386,12 +385,12 @@ disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리 registration: "등록" invite: "초대" driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" -driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량" +driveCapacityPerRemoteAccount: "리모트 유저별 드라이브 용량" inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" basicInfo: "기본 정보" -pinnedUsers: "고정한 사용자" +pinnedUsers: "고정한 유저" pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다." pinnedPages: "고정한 페이지" pinnedPagesDescription: "서버의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다." @@ -419,12 +418,13 @@ antennas: "안테나" manageAntennas: "안테나 관리" name: "이름" antennaSource: "받을 소스" -antennaKeywords: "받을 검색어" -antennaExcludeKeywords: "제외할 검색어" +antennaKeywords: "받을 키워드" +antennaExcludeKeywords: "제외할 키워드" antennaExcludeBots: "봇 계정 제외" antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다" notifyAntenna: "새로운 노트를 알림" withFileAntenna: "파일이 첨부된 노트만" +excludeNotesInSensitiveChannel: "민감한 채널의 노트 제외" enableServiceworker: "ServiceWorker 사용" antennaUsersDescription: "유저명을 한 줄에 한 명씩 적습니다" caseSensitive: "대소문자를 구분" @@ -436,11 +436,11 @@ silence: "사일런스" silenceConfirm: "이 계정을 사일런스로 설정하시겠습니까?" unsilence: "사일런스 해제" unsilenceConfirm: "이 계정의 사일런스를 해제하시겠습니까?" -popularUsers: "인기 사용자" -recentlyUpdatedUsers: "최근에 활동한 사용자" -recentlyRegisteredUsers: "최근에 가입한 사용자" -recentlyDiscoveredUsers: "최근에 발견한 사용자" -exploreUsersCount: "{count}명의 사용자가 있습니다" +popularUsers: "인기 유저" +recentlyUpdatedUsers: "최근에 활동한 유저" +recentlyRegisteredUsers: "최근에 가입한 유저" +recentlyDiscoveredUsers: "최근에 발견한 유저" +exploreUsersCount: "{count}명의 유저가 있습니다" exploreFediverse: "연합우주를 탐색" popularTags: "인기 태그" userList: "리스트" @@ -489,10 +489,8 @@ next: "다음" retype: "다시 입력" noteOf: "{user}의 노트" quoteAttached: "인용함" -quoteQuestion: "인용해서 작성하시겠습니까?" +quoteQuestion: "인용해서 첨부하시겠습니까?" attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?" -noMessagesYet: "아직 대화가 없습니다" -newMessageExists: "새 메시지가 있습니다" onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" signinRequired: "진행하기 전에 로그인을 해 주세요" signinOrContinueOnRemote: "계속하려면 사용하는 서버로 이동하거나 이 서버에 로그인해야 합니다." @@ -510,7 +508,7 @@ strongPassword: "강한 비밀번호" passwordMatched: "일치합니다" passwordNotMatched: "일치하지 않습니다" signinWith: "{x}로 로그인" -signinFailed: "로그인할 수 없습니다. 사용자 이름과 비밀번호를 확인해 주십시오." +signinFailed: "로그인할 수 없습니다. 유저 이름과 비밀번호를 확인해 주십시오." or: "혹은" language: "언어" uiLanguage: "UI 표시 언어" @@ -522,7 +520,7 @@ style: "스타일" drawer: "서랍" popup: "팝업" showNoteActionsOnlyHover: "마우스가 올라간 때에만 노트 동작 버튼을 표시하기" -showReactionsCount: "노트의 반응 수를 표시하기" +showReactionsCount: "노트의 리액션 수를 표시하기" noHistory: "기록이 없습니다" signinHistory: "로그인 기록" enableAdvancedMfm: "고급 MFM을 활성화" @@ -573,8 +571,8 @@ objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급합니다. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있습니다." serverLogs: "서버 로그" deleteAll: "모두 삭제" -showFixedPostForm: "타임라인 상단에 글 작성란을 표시" -showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" +showFixedPostForm: "타임라인 상단에 글 입력란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시" withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기" newNoteRecived: "새 노트가 있습니다" sounds: "소리" @@ -609,7 +607,7 @@ uiInspectorDescription: "메모리에 있는 UI 컴포넌트의 인스턴트 목 output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" -updateRemoteUser: "원격 사용자 정보 갱신" +updateRemoteUser: "리모트 유저 정보 갱신" unsetUserAvatar: "아바타 제거" unsetUserAvatarConfirm: "아바타를 제거할까요?" unsetUserBanner: "배너 제거" @@ -618,7 +616,7 @@ deleteAllFiles: "모든 파일 삭제" deleteAllFilesConfirm: "모든 파일을 삭제하시겠습니까?" removeAllFollowing: "모든 팔로잉 해제" removeAllFollowingDescription: "{host} 서버의 모든 팔로잉을 해제합니다. 해당 서버가 더 이상 존재하지 않는 경우 등에 실행해 주세요." -userSuspended: "이 사용자는 정지되었습니다." +userSuspended: "이 유저는 정지되었습니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." @@ -679,7 +677,7 @@ emailAddress: "메일 주소" smtpConfig: "SMTP 서버 설정" smtpHost: "호스트" smtpPort: "포트" -smtpUser: "사용자 이름" +smtpUser: "유저 이름" smtpPass: "비밀번호" emptyToDisableSmtpAuth: "SMTP 인증을 사용하지 않으려면 공란으로 비워둡니다." smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" @@ -698,6 +696,7 @@ userSaysSomethingAbout: "{name}님이 \"{word}\"를 언급했습니다." makeActive: "활성화" display: "보기" copy: "복사" +copiedToClipboard: "클립보드에 복사되었습니다." metrics: "통계" overview: "요약" logs: "로그" @@ -721,7 +720,7 @@ abuseReports: "신고" reportAbuse: "신고" reportAbuseRenote: "리노트 신고하기" reportAbuseOf: "{name} 신고하기" -fillAbuseReportDescription: "신고하려는 이유를 자세히 알려주세요. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해 주세요." +fillAbuseReportDescription: "신고 사유를 자세히 기재해 주세요. 대상 노트나 페이지 등이 있는 경우에는 해당 URL도 기재해 주세요." abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" @@ -755,8 +754,8 @@ repliedCount: "받은 답글 수" renotedCount: "받은 리노트 수" followingCount: "팔로우 수" followersCount: "팔로워 수" -sentReactionsCount: "반응 수" -receivedReactionsCount: "받은 반응 수" +sentReactionsCount: "리액션 수" +receivedReactionsCount: "받은 리액션 수" pollVotesCount: "투표 수" pollVotedCount: "받은 투표 수" yes: "예" @@ -764,7 +763,7 @@ no: "아니오" driveFilesCount: "드라이브에 있는 파일 수" driveUsage: "드라이브 사용량" noCrawle: "검색엔진의 인덱싱 거부" -noCrawleDescription: "검색엔진에 사용자 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." +noCrawleDescription: "검색엔진에 유저 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공개 범위를 '팔로워'로 하지 않는 한 누구나 당신의 노트를 볼 수 있습니다." alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" @@ -796,7 +795,7 @@ needReloadToApply: "변경 사항은 새로고침하면 적용됩니다." showTitlebar: "타이틀 바를 표시하기" clearCache: "캐시 비우기" onlineUsersCount: "{n}명이 접속 중" -nUsers: "{n} 사용자" +nUsers: "{n} 유저" nNotes: "{n} 노트" sendErrorReports: "오류 보고서 보내기" sendErrorReportsDescription: "이 설정을 활성화하면, 문제가 발생했을 때 오류에 대한 상세 정보를 Misskey에 보내어 더 나은 소프트웨어를 만드는 데에 도움을 줄 수 있습니다." @@ -826,7 +825,7 @@ editCode: "코드 수정" apply: "적용" receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신할게요" emailNotification: "메일 알림" -publish: "게시" +publish: "공개" inChannelSearch: "채널에서 검색" useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" typingUsers: "{users}님이 입력 중" @@ -842,7 +841,7 @@ addDescription: "설명 추가" userPagePinTip: "각 노트의 메뉴에서 「프로필에 고정」을 선택하는 것으로, 여기에 노트를 표시해 둘 수 있어요." notSpecifiedMentionWarning: "수신자가 선택되지 않은 멘션이 있어요" info: "정보" -userInfo: "사용자 정보" +userInfo: "유저 정보" unknown: "알 수 없음" onlineStatus: "온라인 상태" hideOnlineStatus: "온라인 상태 숨기기" @@ -858,7 +857,7 @@ switchAccount: "계정 바꾸기" enabled: "활성화" disabled: "비활성화" quickAction: "빠른 동작" -user: "사용자" +user: "유저" administration: "관리" accounts: "계정" switch: "전환" @@ -869,8 +868,8 @@ configure: "설정하기" postToGallery: "갤러리에 업로드" postToHashtag: "이 해시태그에 게시" gallery: "갤러리" -recentPosts: "최근 포스트" -popularPosts: "인기 포스트" +recentPosts: "최근 게시물" +popularPosts: "인기 게시물" shareWithNote: "노트로 공유" ads: "광고" expiration: "기한" @@ -899,7 +898,7 @@ whatIsNew: "패치 정보 보기" translate: "번역" translatedFrom: "{x}에서 번역" accountDeletionInProgress: "계정 삭제 작업을 진행하고 있습니다" -usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 사용자명은 나중에 변경할 수 없습니다." +usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. 유저명은 나중에 변경할 수 없습니다." aiChanMode: "아이 모드" devMode: "개발자 모드" keepCw: "CW 유지하기" @@ -1033,7 +1032,7 @@ correspondingSourceIsAvailable: "소스 코드는 {anchor}에서 받아보실 roles: "역할" role: "역할" noRole: "역할이 없습니다" -normalUser: "일반 사용자" +normalUser: "일반 유저" undefined: "정의되지 않음" assign: "할당" unassign: "할당 취소" @@ -1057,7 +1056,7 @@ thisPostMayBeAnnoyingHome: "홈에 게시" thisPostMayBeAnnoyingCancel: "그만두기" thisPostMayBeAnnoyingIgnore: "이대로 게시" collapseRenotes: "이미 본 리노트를 간략화하기" -collapseRenotesDescription: "반응이나 리노트를 한 노트를 접어서 표시합니다." +collapseRenotesDescription: "리액션이나 리노트를 한 노트를 접어서 표시합니다." internalServerError: "내부 서버 오류" internalServerErrorDescription: "내부 서버에서 예기치 않은 오류가 발생했습니다." copyErrorInfo: "오류 정보 복사" @@ -1081,8 +1080,8 @@ resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?" sensitiveWords: "민감한 단어" sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다." sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." -prohibitedWords: "금지 워드" -prohibitedWordsDescription: "설정된 워드가 포함되는 노트를 작성하려고 하면, 에러가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." +prohibitedWords: "금지 단어" +prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 게시하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." hiddenTags: "숨긴 해시태그" hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다." @@ -1107,7 +1106,7 @@ audio: "소리" audioFiles: "소리" dataSaver: "데이터 절약 모드" accountMigration: "계정 이동" -accountMoved: "이 사용자는 다음 계정으로 이사했습니다:" +accountMoved: "이 유저는 다음 계정으로 이사했습니다:" accountMovedShort: "이사한 계정입니다" operationForbidden: "사용할 수 없습니다" forceShowAds: "광고를 항상 표시" @@ -1128,8 +1127,8 @@ serverRules: "서버 규칙" pleaseConfirmBelowBeforeSignup: "이 서버에 가입하기 전에 아래 사항을 확인하여 주십시오." pleaseAgreeAllToContinue: "계속하시려면 모든 항목에 동의하십시오." continue: "계속" -preservedUsernames: "예약한 사용자 이름" -preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." +preservedUsernames: "예약한 유저명" +preservedUsernamesDescription: "예약할 유저명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 유저명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." createNoteFromTheFile: "이 파일로 노트를 작성" archive: "아카이브" archived: "아카이브 됨" @@ -1143,7 +1142,7 @@ youFollowing: "팔로잉" preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다." options: "옵션" -specifyUser: "사용자 지정" +specifyUser: "유저 지정" lookupConfirm: "조회 할까요?" openTagPageConfirm: "해시태그의 페이지를 열까요?" specifyHost: "호스트 지정" @@ -1294,12 +1293,12 @@ thereAreNChanges: "{n}건 변경이 있습니다." signinWithPasskey: "패스키로 로그인" unknownWebAuthnKey: "등록되지 않은 패스키입니다." passkeyVerificationFailed: "패스키 검증을 실패했습니다." -passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "입력된 패스키는 정상적이나, 비밀번호 없이 로그인 하는 기능이 비활성화 되어있습니다." messageToFollower: "팔로워에게 보낼 메시지" target: "대상" testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. 실제 환경에서는 사용하지 마세요." -prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)" -prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다." +prohibitedWordsForNameOfUser: "금지 단어 (유저명)" +prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 유저명에 있는 경우, 일반 유저는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 유저는 제한 대상에서 제외됩니다." yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다." yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요." thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해야 볼 수 있도록 설정되어 있습니다." @@ -1325,20 +1324,105 @@ skip: "건너뛰기" restore: "복원" syncBetweenDevices: "장치간 동기화" preferenceSyncConflictTitle: "서버에 설정값이 존재합니다." +preferenceSyncConflictText: "동기화를 활성화 한 항목의 설정 값은 서버에 저장되지만, 해당 항목은 이미 서버에 설정 값이 저장되어져 있습니다. 어느 쪽의 설정 값을 덮어씌울까요?" preferenceSyncConflictChoiceServer: "서버 설정값" preferenceSyncConflictChoiceDevice: "장치 설정값" +preferenceSyncConflictChoiceCancel: "동기화 취소" paste: "붙여넣기" emojiPalette: "이모지 팔레트" postForm: "글 입력란" +textCount: "문자 수" +information: "정보" +chat: "채팅" +migrateOldSettings: "기존 설정 정보를 이전" +migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다." +compress: "압축" +right: "오른쪽" +bottom: "아래" +top: "위" +embed: "임베드" +settingsMigrating: "설정을 이전하는 중입니다. 잠시 기다려주십시오... (나중에 '환경설정 → 기타 → 기존 설정 정보를 이전'에서 수동으로 이전할 수도 있습니다)" +readonly: "읽기 전용" +goToDeck: "덱으로 돌아가기" +_chat: + noMessagesYet: "아직 메시지가 없습니다" + newMessage: "새로운 메시지" + individualChat: "개인 대화" + individualChat_description: "특정 유저와 일대일 채팅을 할 수 있습니다." + roomChat: "룸 채팅" + roomChat_description: "여러 명이 함께 채팅할 수 있습니다.\n또한, 개인 채팅을 허용하지 않은 유저와도 상대방이 수락하면 채팅을 할 수 있습니다." + createRoom: "룸을 생성" + inviteUserToChat: "유저를 초대하여 채팅을 시작하세요" + yourRooms: "생성한 룸" + joiningRooms: "참가 중인 룸" + invitations: "초대" + noInvitations: "초대장이 없습니다" + history: "이력" + noHistory: "기록이 없습니다" + noRooms: "룸이 없습니다" + inviteUser: "유저를 초대" + sentInvitations: "초대를 보내기" + join: "참여" + ignore: "무시" + leave: "룸을 떠나기" + members: "멤버" + searchMessages: "메시지 검색" + home: "홈" + send: "전송" + newline: "줄바꿈" + muteThisRoom: "이 룸을 뮤트" + deleteRoom: "룸을 삭제" + chatNotAvailableForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다." + chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅 룸을 만들거나 참가할 수 없습니다." + chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다." + cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다" + cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다." + chatWithThisUser: "채팅하기" + thisUserAllowsChatOnlyFromFollowers: "이 유저는 팔로워만 채팅을 할 수 있습니다." + thisUserAllowsChatOnlyFromFollowing: "이 유저는 이 유저가 팔로우하는 유저만 채팅을 허용합니다." + thisUserAllowsChatOnlyFromMutualFollowing: "이 유저는 상호 팔로우하는 유저만 채팅을 허용합니다." + thisUserNotAllowedChatAnyone: "이 유저는 다른 사람의 채팅을 받지 않습니다." + chatAllowedUsers: "채팅을 허용한 상대" + chatAllowedUsers_note: "내가 채팅 메시지를 보낸 상대와는 이 설정과 상관없이 채팅이 가능합니다." + _chatAllowedUsers: + everyone: "누구나" + followers: "자신의 팔로워만" + following: "자신이 팔로우한 유저만" + mutual: "상호 팔로우한 유저만" + none: "아무도 허락하지 않기" _emojiPalette: palettes: "팔레트" + enableSyncBetweenDevicesForPalettes: "팔레트의 디바이스 간 동기화를 활성화" paletteForMain: "메인으로 사용할 팔레트" paletteForReaction: "리액션으로 사용할 팔레트" _settings: + driveBanner: "드라이브 관리, 사용량 확인, 파일 업로드에 관한 설정을 합니다." + pluginBanner: "플러그인을 사용하면 클라이언트 기능을 확장할 수 있습니다. 플러그인 설치와 개별적인 설정을 합니다." + notificationsBanner: "서버에서 받는 알림의 종류 및 범위, 푸시 알림 설정을 합니다." api: "API" webhook: "Webhook" serviceConnection: "서비스 연동" + serviceConnectionBanner: "외부 앱, 서비스와 연결하기 위한 액세스 토큰과 웹 훅 관리 설정을 합니다." accountData: "계정 데이터" + accountDataBanner: "계정 데이터의 아카이브를 추출하기/가져오기 하여 관리할 수 있습니다." + muteAndBlockBanner: "숨길 컨텐츠의 설정과, 특정 유저의 리액션을 제한하는 설정을 관리합니다." + accessibilityBanner: "좀 더 쾌적하게 사용할 수 있도록 클라이언트의 시각 및 움직임에 관한 개인화 설정을 합니다." + privacyBanner: "컨텐츠, 계정의 발견 범위, 팔로우 승인제 등의 계정의 프라이버시에 관한 설정을 합니다." + securityBanner: "비밀번호, 로그인 방법, OTP, 패스 키 등의 계정의 보안에 관련된 설정을 합니다." + preferencesBanner: "취향에 알맞는 클라이언트의 전체적인 동작을 설정합니다." + appearanceBanner: "취향에 알맞는 클라이언트의 디스플레이, 표시 방법에 관한 설정을 합니다." + soundsBanner: "클라이언트에서 재생할 소리에 대한 설정을 합니다." + timelineAndNote: "타임라인과 노트" + makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함" + makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 유저의 접근성이 나빠질 수도 있습니다." + useStickyIcons: "아이콘이 스크롤을 따라가도록 하기" + showNavbarSubButtons: "내비게이션 바에 보조 버튼 표시" + ifOn: "켜져 있을 때" + ifOff: "꺼져 있을 때" + enableSyncThemesBetweenDevices: "기기 간 설치한 테마 동기화" + _chat: + showSenderName: "발신자 이름 표시" + sendOnEnter: "엔터로 보내기" _preferencesProfile: profileName: "프로필 이름" profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." @@ -1356,12 +1440,13 @@ _accountSettings: requireSigninToViewContents: "콘텐츠 열람을 위해 로그인을 필수로 설정하기" requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다." requireSigninToViewContentsDescription2: "URL 미리보기(OGP), 웹페이지에 삽입, 노트 인용을 지원하지 않는 서버에서 볼 수 없게 됩니다." - requireSigninToViewContentsDescription3: "원격 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." + requireSigninToViewContentsDescription3: "리모트 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." makeNotesFollowersOnlyBefore: "과거 노트는 팔로워만 볼 수 있도록 설정하기" makeNotesFollowersOnlyBeforeDescription: "이 기능이 활성화되어 있는 동안, 설정된 날짜 및 시간보다 과거 또는 설정된 시간이 지난 노트는 팔로워만 볼 수 있게 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." makeNotesHiddenBefore: "과거 노트 비공개로 전환하기" makeNotesHiddenBeforeDescription: "이 기능이 활성화되어 있는 동안 설정한 날짜 및 시간보다 과거 또는 설정한 시간이 지난 노트는 본인만 볼 수 있게(비공개로 전환) 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." - mayNotEffectForFederatedNotes: "원격 서버에 연합된 노트에는 효과가 없을 수도 있습니다." + mayNotEffectForFederatedNotes: "리모트 서버에 연합된 노트에는 효과가 없을 수도 있습니다." + mayNotEffectSomeSituations: "여기서 설정하는 제한은 모더레이션이나 리모트 서버에서 볼 때 등 일부 환경에서는 적용되지 않을 수도 있습니다." notesHavePassedSpecifiedPeriod: "지정한 시간이 경과된 노트" notesOlderThanSpecifiedDateAndTime: "지정된 날짜 및 시간 이전의 노트" _abuseUserReport: @@ -1401,11 +1486,11 @@ _announcement: needConfirmationToRead: "읽음으로 표시하기 전에 확인하기" needConfirmationToReadDescription: "활성화하면 이 공지사항을 읽음으로 표시하기 전에 확인 알림창을 띄웁니다. '모두 읽음'의 대상에서도 제외됩니다." end: "공지에서 내리기" - tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 사용자 경험에 영향을 끼칠 가능성이 있습니다. 오래된 공지사항은 아카이브하시는 것을 권장드립니다." + tooManyActiveAnnouncementDescription: "공지사항이 너무 많을 경우, 유저 경험에 영향을 끼칠 가능성이 있습니다. 오래된 공지사항은 아카이브하시는 것을 권장드립니다." readConfirmTitle: "읽음으로 표시합니까?" readConfirmText: "〈{title}〉의 내용을 읽음으로 표시합니다." shouldNotBeUsedToPresentPermanentInfo: "신규 유저의 이용 경험에 악영향을 끼칠 수 있으므로, 일시적인 알림 수단으로만 사용하고 고정된 정보에는 사용을 지양하는 것을 추천합니다." - dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 사용자 경험에 악영향을 끼칠 수 있으므로 신중히 결정하십시오." + dialogAnnouncementUxWarn: "다이얼로그 형태의 알림이 동시에 2개 이상 존재하는 경우, 유저 경험에 악영향을 끼칠 수 있으므로 신중히 결정하십시오." silence: "조용히 알림" silenceDescription: "활성화하면 공지사항에 대한 알림이 가지 않게 되며, 확인 버튼을 누를 필요가 없게 됩니다." _initialAccountSetting: @@ -1457,7 +1542,7 @@ _initialTutorial: description3: "이 외에도, '리스트 타임라인'이나 '채널 타임라인' 등이 있습니다. 자세한 사항은 {link}에서 확인하실 수 있습니다." _postNote: title: "노트 게시 설정" - description1: "Misskey에 노트를 쓸 때에는 다양한 옵션을 설정할 수 있습니다. 노트를 작성하는 화면은 이렇게 생겼습니다." + description1: "Misskey에 노트를 게시할 때에는 다양한 옵션 설정이 가능합니다. 노트를 게시할 때 쓰이는 '글 입력란'은 이렇게 생겼습니다." _visibility: description: "노트를 볼 수 있는 사람을 제한할 수 있습니다." public: "모든 유저에게 공개합니다." @@ -1477,7 +1562,7 @@ _initialTutorial: _howToMakeAttachmentsSensitive: title: "첨부 파일을 열람주의로 설정하려면?" description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 설정해 주세요." - tryThisFile: "이 작성 창에 첨부된 이미지를 열람 주의로 설정해 보세요!" + tryThisFile: "이 입력란에 첨부된 이미지를 열람 주의로 설정해 보세요!" _exampleNote: note: "낫또 뚜껑 뜯다가 실수했다…" method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭합니다." @@ -1531,53 +1616,53 @@ _achievements: _types: _notes1: title: "미스키 계정 만들었어요" - description: "첫 노트를 작성했습니다" + description: "첫 노트를 게시했다" flavor: "Misskey에 어서 오세요!" _notes10: title: "몇 가지 노트" - description: "10개의 노트를 작성했습니다" + description: "10개의 노트를 게시했다" _notes100: title: "많은 노트" - description: "100개의 노트를 작성했습니다" + description: "100개의 노트를 게시했다" _notes500: title: "노트 범벅" - description: "500개의 노트를 작성했습니다" + description: "500개의 노트를 게시했다" _notes1000: title: "노트가 산더미" - description: "1,000개의 노트를 작성했습니다" + description: "1,000개의 노트를 게시했다" _notes5000: title: "솟아나는 노트" - description: "5,000개의 노트를 작성했습니다" + description: "5,000개의 노트를 게시했다" _notes10000: title: "슈퍼 노트" - description: "10,000개의 노트를 작성했습니다" + description: "10,000개의 노트를 게시했다" _notes20000: - title: "노트가 필요해요" - description: "20,000개의 노트를 작성했습니다" + title: "노트가 더 필요해요" + description: "20,000개의 노트를 게시했다" _notes30000: title: "노트노트노트" - description: "30,000개의 노트를 작성했습니다" + description: "30,000개의 노트를 게시했다" _notes40000: title: "노트 공장" - description: "40,000개의 노트를 작성했습니다" + description: "40,000개의 노트를 게시했다" _notes50000: title: "노트 행성" - description: "50,000개의 노트를 작성했습니다" + description: "50,000개의 노트를 게시했다" _notes60000: title: "노트 퀘이사" - description: "60,000개의 노트를 작성했습니다" + description: "60,000개의 노트를 게시했다" _notes70000: title: "노트 블랙홀" - description: "70,000개의 노트를 작성했습니다" + description: "70,000개의 노트를 게시했다" _notes80000: title: "노트 은하" - description: "80,000개의 노트를 작성했습니다" + description: "80,000개의 노트를 게시했다" _notes90000: title: "노트 우주" - description: "90,000개의 노트를 작성했습니다" + description: "90,000개의 노트를 게시했다" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" - description: "100,000개의 노트를 작성했습니다" + description: "100,000개의 노트를 게시했다" flavor: "이렇게나 쓸 게 있어요?" _login3: title: "초보자 I" @@ -1602,181 +1687,181 @@ _achievements: flavor: "그 유저, 미스키스트이다" _login200: title: "단골 I" - description: "총 200일간 로그인했습니다" + description: "총 로그인한 날이 200일" _login300: title: "단골 II" - description: "총 300일간 로그인했습니다" + description: "총 로그인한 날이 300일" _login400: title: "단골 III" - description: "총 400일간 로그인했습니다" + description: "총 로그인한 날이 400일" _login500: title: "베테랑 I" - description: "총 500일간 로그인했습니다" + description: "총 로그인한 날이 500일" flavor: "제군, 나는 노트가 좋다" _login600: title: "베테랑 II" - description: "총 600일간 로그인했습니다" + description: "총 로그인한 날이 600일" _login700: title: "베테랑 III" - description: "총 700일간 로그인했습니다" + description: "총 로그인한 날이 700일" _login800: title: "노트 마스터 I" - description: "총 800일간 로그인했습니다" + description: "총 로그인한 날이 800일" _login900: title: "노트 마스터 II" - description: "총 900일간 로그인했습니다" + description: "총 로그인한 날이 900일" _login1000: title: "노트 마스터 III" - description: "총 1,000일간 로그인했습니다" + description: "총 로그인한 날이 1,000일" flavor: "Misskey를 사용해 주셔서 감사합니다!" _noteClipped1: title: "클립할 수밖에 없었어" - description: "처음으로 노트를 클립했습니다" + description: "처음으로 노트를 클립했다" _noteFavorited1: title: "별을 바라보는 자" - description: "처음으로 노트를 즐겨찾기했습니다" + description: "처음으로 노트를 즐겨찾기했다" _myNoteFavorited1: title: "별을 원하는 자" - description: "다른 사람이 당신의 노트를 즐겨찾기했습니다" + description: "다른 사람이 당신의 노트를 즐겨찾기했다" _profileFilled: title: "준비 완료" - description: "프로필 설정을 완료했습니다" + description: "프로필 설정을 완료했다" _markedAsCat: title: "나는 고양이다냥!" - description: "계정을 고양이로 설정했습니다냥" + description: "계정을 고양이로 설정했다냥" flavor: "냐냐냐냐냐냐아아아아앙!" _following1: title: "첫 팔로우" - description: "사용자를 처음으로 팔로우했습니다" + description: "유저를 처음으로 팔로우했다" _following10: title: "팔로우, 팔로우" - description: "10명의 사용자를 팔로우했습니다" + description: "10명의 유저를 팔로우했다" _following50: title: "친구 잔뜩" - description: "50명의 사용자를 팔로우했습니다" + description: "50명의 유저를 팔로우했다" _following100: title: "주소록 한 권으론 부족해" - description: "100명의 사용자를 팔로우했습니다" + description: "100명의 유저를 팔로우했다" _following300: title: "친구가 넘쳐나" - description: "300명의 사용자를 팔로우했습니다" + description: "300명의 유저를 팔로우했다" _followers1: title: "첫 팔로워" - description: "사용자가 처음으로 팔로잉했습니다" + description: "유저가 처음으로 팔로잉했다" _followers10: title: "팔로우 미!" - description: "10명의 사용자가 팔로우했습니다" + description: "10명의 유저가 팔로우했다" _followers50: title: "이곳저곳" - description: "50명의 사용자가 팔로우했습니다" + description: "50명의 유저가 팔로우했다" _followers100: title: "인기왕" - description: "100명의 사용자가 팔로우했습니다" + description: "100명의 유저가 팔로우했다" _followers300: title: "줄 좀 서봐요" - description: "100명의 사용자가 팔로우했습니다" + description: "100명의 유저가 팔로우했다" _followers500: title: "기지국" - description: "500명의 사용자가 팔로우했습니다" + description: "500명의 유저가 팔로우했다" _followers1000: title: "유명인사" - description: "1,000명의 사용자가 팔로우했습니다" + description: "1,000명의 유저가 팔로우했다" _collectAchievements30: title: "도전 과제 콜렉터" - description: "30개의 도전과제를 획득했습니다" + description: "30개의 도전과제를 획득했다" _viewAchievements3min: title: "저 도전과제 좋아해요" - description: "도전 과제 목록을 3분 이상 쳐다봤습니다" + description: "도전 과제 목록을 3분 이상 쳐다봤다" _iLoveMisskey: title: "I Love Misskey" - description: "\"I ❤ #Misskey\"를 포스트했습니다" + description: "\"I ❤ #Misskey\"를 게시했다" flavor: "Misskey를 이용해 주셔서 감사합니다! ― 개발 팀" _foundTreasure: title: "보물찾기" - description: "숨겨진 보물을 발견했습니다" + description: "숨겨진 보물을 발견했다" _client30min: title: "잠시 쉬어요" - description: "클라이언트를 시작하고 30분이 경과하였습니다" + description: "클라이언트를 시작하고 30분이 경과했다" _client60min: title: "No \"Miss\" in Misskey" - description: "클라이언트를 시작하고 60분이 경과하였습니다" + description: "클라이언트를 시작하고 60분이 경과했다" _noteDeletedWithin1min: title: "있었는데요 없었습니다" - description: "노트를 포스트한 후 1분 이내에 삭제했습니다" + description: "노트를 게시한 후 1분 이내에 삭제했다" _postedAtLateNight: title: "올빼미" - description: "한밤중에 노트를 포스트했습니다" + description: "한밤중에 노트를 게시했다" flavor: "잠 좀 자세요. 걱정돼요." _postedAt0min0sec: title: "정각" - description: "0분 0초 정각에 노트를 작성했습니다" + description: "0분 0초 정각에 노트를 게시했다" flavor: "째깍 째깍 째깍 땡!" _selfQuote: title: "혼잣말" - description: "자기 노트를 인용했습니다" + description: "자기 노트를 인용했다" _htl20npm: title: "타임라인 폭주 중" - description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었습니다" + description: "1분 사이에 홈 타임라인에 노트가 20개 넘게 생성되었다" _viewInstanceChart: title: "애널리스트" - description: "서버의 차트를 열었습니다" + description: "서버의 차트를 열었다" _outputHelloWorldOnScratchpad: title: "Hello, world!" - description: "스크래치패드에서 hello world를 출력했습니다" + description: "스크래치패드에서 hello world를 출력했다" _open3windows: title: "멀티 윈도우" - description: "3개 이상의 창을 열었습니다" + description: "3개 이상의 창을 열었다" _driveFolderCircularReference: title: "순환 참조" - description: "드라이브 폴더에 스스로를 넣게 했습니다" + description: "드라이브 폴더에 스스로를 넣게 했다" _reactWithoutRead: title: "읽고 답하긴 하시는 건가요?" - description: "100자가 넘는 노트를 작성한 지 3초 안에 반응했어요" + description: "100자가 넘는 노트를 게시한 지 3초 안에 리액션했다" _clickedClickHere: - title: "여기를 누르세요" - description: "여기를 눌렀습니다" + title: "여길 눌러보세요" + description: "여기를 눌렀다" _justPlainLucky: title: "그냥 운이 좋았어" - description: "매 10초마다 0.01%의 확률로 달성됩니다" + description: "매 10초마다 0.01%의 확률로 달성된다" _setNameToSyuilo: title: "신 콤플렉스" - description: "이름을 syuilo로 설정했습니다" + description: "이름을 syuilo로 설정했다" _passedSinceAccountCreated1: title: "1주년" - description: "계정을 생성하고 1년이 지났습니다" + description: "계정을 생성하고 1년이 지났다" _passedSinceAccountCreated2: title: "2주년" - description: "계정을 생성하고 2년이 지났습니다" + description: "계정을 생성하고 2년이 지났다" _passedSinceAccountCreated3: title: "3주년" - description: "계정을 생성하고 3년이 지났습니다" + description: "계정을 생성하고 3년이 지났다" _loggedInOnBirthday: title: "생일 축하합니다!" - description: "생일에 로그인했습니다" + description: "생일에 로그인했다" _loggedInOnNewYearsDay: title: "새해 복 많이 받으세요" - description: "새해 첫 날에 로그인했습니다" + description: "새해 첫 날에 로그인했다" flavor: "올해에도 저희 서버에 관심을 가져 주셔서 감사합니다" _cookieClicked: title: "쿠키를 클릭하는 게임" - description: "쿠키를 클릭했습니다" + description: "쿠키를 클릭했다" flavor: "소프트웨어 착각하지 않으셨나요?" _brainDiver: title: "Brain Diver" - description: "Brain Diver로의 링크를 첨부했습니다" + description: "Brain Diver로의 링크를 첨부했다" flavor: "Misskey-Misskey La-Tu-Ma" _smashTestNotificationButton: title: "테스트 과잉" - description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했습니다" + description: "매우 짧은 시간 안에 알림 테스트를 여러 번 수행했다" _tutorialCompleted: title: "Misskey 입문자 과정 수료증" - description: "튜토리얼을 완료했습니다" + description: "튜토리얼을 완료했다" _bubbleGameExplodingHead: title: "🤯" description: "버블 게임에서 가장 큰 물건을 내놓았다" _bubbleGameDoubleExplodingHead: title: "더블 🤯" - description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다." + description: "버블게임에서 가장 큰 물건 2개를 동시에 내놓았다" flavor: "이 정도만 도시락통에 🤯 🤯 조금만 더" _role: new: "새 역할 생성" @@ -1786,7 +1871,7 @@ _role: permission: "역할 권한" descriptionOfPermission: "조정자는 기본적인 조정 작업을 진행할 수 있습니다.\n관리자는 서버의 모든 설정을 변경할 수 있습니다." assignTarget: "할당 대상" - descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n조건부를 선택하면 조건을 설정해 일치하는 사용자를 자동으로 포함되게 할 수 있습니다." + descriptionOfAssignTarget: "수동을 선택하면 누가 이 역할에 포함되는지를 수동으로 관리할 수 있습니다.\n조건부를 선택하면 조건을 설정해 일치하는 유저를 자동으로 포함되게 할 수 있습니다." manual: "수동" manualRoles: "수동 역할" conditional: "조건부" @@ -1794,7 +1879,7 @@ _role: condition: "조건" isConditionalRole: "조건부 역할입니다." isPublic: "역할 공개" - descriptionOfIsPublic: "역할에 할당된 사용자를 누구나 볼 수 있습니다. 또한 사용자 프로필에 이 역할이 표시됩니다." + descriptionOfIsPublic: "역할에 할당된 유저를 누구나 볼 수 있습니다. 또한 유저 프로필에 이 역할이 표시됩니다." options: "옵션" policies: "정책" baseRole: "기본 역할" @@ -1807,8 +1892,10 @@ _role: descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." displayOrder: "표시 순서" descriptionOfDisplayOrder: "값이 클 수록 UI에서 먼저 표시됩니다." + preserveAssignmentOnMoveAccount: "이전 대상 계정에도 할당 상태 전달" + preserveAssignmentOnMoveAccount_description: "켜면 이 역할이 부여된 계정이 이전될 때 마이그레이션 대상 계정에도 이 역할이 승계됩니다." canEditMembersByModerator: "모더레이터의 역할 수정 허용" - descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 사용자를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." + descriptionOfCanEditMembersByModerator: "이 옵션을 켜면 모더레이터도 이 역할에 유저를 할당하거나 삭제할 수 있습니다. 꺼져 있으면 관리자만 할당이 가능합니다." priority: "우선순위" _priority: low: "낮음" @@ -1834,8 +1921,8 @@ _role: webhookMax: "만들 수 있는 Webhook 수" clipMax: "만들 수 있는 클립 수" noteEachClipsMax: "클립에 넣을 수 있는 노트 수" - userListMax: "만들 수 있는 사용자 리스트 수" - userEachUserListsMax: "사용자 리스트에 넣을 수 있는 사용자 수" + userListMax: "만들 수 있는 유저 리스트 수" + userEachUserListsMax: "유저 리스트에 넣을 수 있는 유저 수" rateLimitFactor: "요청 빈도 제한" descriptionOfRateLimitFactor: "작을수록 제한이 완화되고, 클수록 제한이 강화됩니다." canHideAds: "광고 숨기기" @@ -1847,23 +1934,24 @@ _role: canImportFollowing: "팔로우 가져오기 허용" canImportMuting: "뮤트 목록 가져오기 허용" canImportUserLists: "리스트 목록 가져오기 허용" + chatAvailability: "채팅을 허락" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" - isLocal: "로컬 사용자" - isRemote: "원격 사용자" - isCat: "고양이 사용자" - isBot: "봇 사용자" - isSuspended: "정지된 사용자" - isLocked: "잠금 계정 사용자" - isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 사용자" + isLocal: "로컬 유저" + isRemote: "리모트 유저" + isCat: "고양이 유저" + isBot: "봇 유저" + isSuspended: "정지된 유저" + isLocked: "잠금 계정 유저" + isExplorable: "‘계정을 쉽게 발견하도록 하기’를 활성화한 유저" createdLessThan: "가입한 지 다음 일수 이내인 유저" createdMoreThan: "가입한 지 다음 일수 이상인 유저" followersLessThanOrEq: "팔로워 수가 다음 이하인 유저" - followersMoreThanOrEq: "팔로워 수가 다음보다 많은 사용자" + followersMoreThanOrEq: "팔로워 수가 다음보다 많은 유저" followingLessThanOrEq: "팔로잉 수가 다음 이하인 유저" - followingMoreThanOrEq: "팔로잉 수가 다음보다 많은 사용자" + followingMoreThanOrEq: "팔로잉 수가 다음보다 많은 유저" notesLessThanOrEq: "노트 수가 다음 이하인 유저" - notesMoreThanOrEq: "노트 수가 다음보다 많은 사용자" + notesMoreThanOrEq: "노트 수가 다음보다 많은 유저" and: "다음을 모두 만족" or: "다음을 하나라도 만족" not: "다음을 만족하지 않음" @@ -1905,7 +1993,7 @@ _ad: adsSettings: "광고 표시 설정" notesPerOneAd: "실시간으로 갱신되는 타임라인에서 광고를 노출시키는 간격 (노트 당)" setZeroToDisable: "0으로 지정하면 실시간 타임라인에서의 광고를 비활성화합니다" - adsTooClose: "광고의 표시 간격이 매우 작아, 사용자 경험에 부정적인 영향을 미칠 수 있습니다." + adsTooClose: "광고의 표시 간격이 매우 작아, 유저 경험에 부정적인 영향을 미칠 수 있습니다." _forgotPassword: enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 주세요. 입력한 메일 주소로 비밀번호 재설정 링크를 발송합니다." ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 주십시오." @@ -2043,7 +2131,6 @@ _theme: header: "헤더" navBg: "사이드바 배경" navFg: "사이드바 텍스트" - navHoverFg: "사이드바 텍스트 (호버)" navActive: "사이드바 텍스트 (활성)" navIndicator: "사이드바 인디케이터" link: "링크" @@ -2066,17 +2153,15 @@ _theme: buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" driveFolderBg: "드라이브 폴더 배경" - wallpaperOverlay: "배경화면 오버레이" badge: "배지" messageBg: "대화 배경" - accentDarken: "강조 색상 (어두움)" - accentLighten: "강조 색상 (밝음)" fgHighlighted: "강조된 텍스트" _sfx: note: "새 노트" noteMy: "내 노트" notification: "알림" reaction: "리액션 선택" + chatMessage: "채팅 메시지" _soundSettings: driveFile: "드라이브에 있는 오디오를 사용" driveFileWarn: "드라이브에 있는 파일을 선택하세요." @@ -2163,7 +2248,7 @@ _permissions: "write:pages": "페이지를 수정합니다" "read:page-likes": "페이지의 좋아요를 확인합니다" "write:page-likes": "페이지에 좋아요를 추가하거나 취소합니다" - "read:user-groups": "사용자 그룹 보기" + "read:user-groups": "유저 그룹 보기" "write:user-groups": "유저 그룹을 만들거나, 초대하거나, 이름을 변경하거나, 양도하거나, 삭제합니다" "read:channels": "채널을 보기" "write:channels": "채널을 추가하거나 삭제합니다" @@ -2175,23 +2260,23 @@ _permissions: "write:flash": "Play를 조작합니다" "read:flash-likes": "Play의 좋아요를 봅니다" "write:flash-likes": "Play의 좋아요를 조작합니다" - "read:admin:abuse-user-reports": "사용자 신고 보기" - "write:admin:delete-account": "사용자 계정 삭제하기" - "write:admin:delete-all-files-of-a-user": "모든 사용자 파일 삭제하기" + "read:admin:abuse-user-reports": "유저 신고 보기" + "write:admin:delete-account": "유저 계정 삭제하기" + "write:admin:delete-all-files-of-a-user": "모든 유저 파일 삭제하기" "read:admin:index-stats": "데이터베이스 색인 정보 보기" "read:admin:table-stats": "데이터베이스 테이블 정보 보기" - "read:admin:user-ips": "사용자 IP 주소 보기" + "read:admin:user-ips": "유저 IP 주소 보기" "read:admin:meta": "인스턴스 메타데이터 보기" - "write:admin:reset-password": "사용자 비밀번호 재설정하기" - "write:admin:resolve-abuse-user-report": "사용자 신고 처리하기" + "write:admin:reset-password": "유저 비밀번호 재설정하기" + "write:admin:resolve-abuse-user-report": "유저 신고 처리하기" "write:admin:send-email": "이메일 보내기" "read:admin:server-info": "서버 정보 보기" "read:admin:show-moderation-log": "조정 기록 보기" - "read:admin:show-user": "사용자 개인정보 보기" - "write:admin:suspend-user": "사용자 정지하기" - "write:admin:unset-user-avatar": "사용자 아바타 삭제하기" - "write:admin:unset-user-banner": "사용자 배너 삭제하기" - "write:admin:unsuspend-user": "사용자 정지 해제하기" + "read:admin:show-user": "유저 개인정보 보기" + "write:admin:suspend-user": "유저 정지하기" + "write:admin:unset-user-avatar": "유저 아바타 삭제하기" + "write:admin:unset-user-banner": "유저 배너 삭제하기" + "write:admin:unsuspend-user": "유저 정지 해제하기" "write:admin:meta": "인스턴스 메타데이터 수정하기" "write:admin:user-note": "조정 기록 수정하기" "write:admin:roles": "역할 수정하기" @@ -2205,15 +2290,15 @@ _permissions: "write:admin:avatar-decorations": "아바타 꾸미기 수정하기" "read:admin:avatar-decorations": "아바타 꾸미기 보기" "write:admin:federation": "연합 정보 수정하기" - "write:admin:account": "사용자 계정 수정하기" - "read:admin:account": "사용자 정보 보기" + "write:admin:account": "유저 계정 수정하기" + "read:admin:account": "유저 정보 보기" "write:admin:emoji": "이모지 수정하기" "read:admin:emoji": "이모지 보기" "write:admin:queue": "작업 대기열 수정하기" "read:admin:queue": "작업 대기열 정보 보기" "write:admin:promo": "홍보 기록 수정하기" - "write:admin:drive": "사용자 드라이브 수정하기" - "read:admin:drive": "사용자 드라이브 정보 보기" + "write:admin:drive": "유저 드라이브 수정하기" + "read:admin:drive": "유저 드라이브 정보 보기" "read:admin:stream": "관리자용 Websocket API 사용하기" "write:admin:ad": "광고 수정하기" "read:admin:ad": "광고 보기" @@ -2223,6 +2308,8 @@ _permissions: "read:clip-favorite": "클립의 좋아요 보기" "read:federation": "연합 정보 불러오기" "write:report-abuse": "위반 내용 신고하기" + "write:chat": "대화를 시작하거나 메시지를 보냅니다" + "read:chat": "채팅 열람하기" _auth: shareAccessTitle: "어플리케이션의 접근 허가" shareAccess: "‘{name}’에서 계정에 접근하는 것을 허용하시겠습니까?" @@ -2233,7 +2320,7 @@ _auth: callback: "앱으로 돌아갑니다" accepted: "접근 권한이 부여되었습니다." denied: "접근이 거부되었습니다" - scopeUser: "다음 사용자로 활동하고 있습니다." + scopeUser: "다음 유저로 활동하고 있습니다." pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오." byClickingYouWillBeRedirectedToThisUrl: "접근을 허용하면 자동으로 다음 URL로 이동합니다." _antennaSources: @@ -2270,7 +2357,7 @@ _widgets: postForm: "글 입력란" slideshow: "슬라이드 쇼" button: "버튼" - onlineUsers: "온라인 사용자" + onlineUsers: "온라인 유저" jobQueue: "작업 대기열" serverMetric: "서버 통계" aiscript: "AiScript 콘솔" @@ -2280,7 +2367,8 @@ _widgets: _userList: chooseList: "리스트 선택" clicker: "클리커" - birthdayFollowings: "오늘이 생일인 사용자" + birthdayFollowings: "오늘이 생일인 유저" + chat: "채팅" _cw: hide: "숨기기" show: "더 보기" @@ -2332,7 +2420,7 @@ _postForm: f: "작성해주시길 기다리고 있어요..." _profile: name: "이름" - username: "사용자 이름" + username: "유저명" description: "자기소개" youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다." metadata: "추가 정보" @@ -2363,7 +2451,7 @@ _charts: apRequest: "요청" usersIncDec: "유저 수 증감" usersTotal: "유저 수 합계" - activeUsers: "활동 사용자 수" + activeUsers: "활동 유저 수" notesIncDec: "노트 수 증감" localNotesIncDec: "로컬 노트 수 증감" remoteNotesIncDec: "리모트 노트 수 증감" @@ -2374,8 +2462,8 @@ _charts: storageUsageTotal: "스토리지 사용량 합계" _instanceCharts: requests: "요청" - users: "사용자 수 차이" - usersTotal: "누적 사용자 수" + users: "유저 수 차이" + usersTotal: "누적 유저 수" notes: "노트 수 증감" notesTotal: "누적 노트 수" ff: "팔로잉/팔로워 증감" @@ -2471,13 +2559,14 @@ _notification: newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" roleAssigned: "역할이 부여 되었습니다." + chatRoomInvitationReceived: "채팅 룸에 초대받았습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" testNotification: "알림 테스트" checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" notificationWillBeDisplayedLikeThis: "알림이 이렇게 표시됩니다" - reactedBySomeUsers: "{n}명이 반응했습니다" + reactedBySomeUsers: "{n}명이 리액션했습니다" likedBySomeUsers: "{n}명이 좋아요를 했습니다" renotedBySomeUsers: "{n}명이 리노트했습니다" followedBySomeUsers: "{n}명에게 팔로우됨" @@ -2488,20 +2577,22 @@ _notification: createTokenDescription: "만약 기억이 나지 않는다면 '{text}'를 통해 액세스 토큰을 삭제해 주세요." _types: all: "전부" - note: "사용자의 새 글" + note: "유저의 새 글" follow: "팔로잉" mention: "멘션" reply: "답글" renote: "리노트" quote: "인용" - reaction: "반응" + reaction: "리액션" pollEnded: "투표가 종료됨" receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" - roleAssigned: "역할이 부여 됨" + roleAssigned: "역할이 부여됨" + chatRoomInvitationReceived: "채팅 룸에 초대받음" achievementEarned: "도전 과제 획득" exportCompleted: "추출을 성공함" login: "로그인" + createToken: "액세스 토큰 만들기" test: "알림 테스트" app: "연동된 앱을 통한 알림" _actions: @@ -2511,6 +2602,9 @@ _notification: _deck: alwaysShowMainColumn: "메인 칼럼 항상 표시" columnAlign: "칼럼 정렬" + columnGap: "칼럼 간 여백" + deckMenuPosition: "덱 메뉴 위치" + navbarPosition: "내비게이션 바 위치" addColumn: "칼럼 추가" newNoteNotificationSettings: "새 노트 알림 설정" configureColumn: "칼럼 설정" @@ -2529,6 +2623,7 @@ _deck: useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기" usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용됩니다" flexible: "폭 자동 조정" + enableSyncBetweenDevicesForProfiles: "프로파일 정보의 디바이스 간 동기화를 활성화" _columns: main: "메인" widgets: "위젯" @@ -2540,6 +2635,7 @@ _deck: mentions: "받은 멘션" direct: "다이렉트" roleTimeline: "역할 타임라인" + chat: "채팅" _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {max}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" @@ -2581,10 +2677,10 @@ _abuseReport: mail: "이메일" webhook: "Webhook" _captions: - mail: "모더레이터 권한을 가진 사용자의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" + mail: "모더레이터 권한을 가진 유저의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" webhook: "지정한 SystemWebhook에 알림을 보냅니다 (신고를 받은 때와 해결했을 때에 송신)" keywords: "키워드" - notifiedUser: "알릴 사용자" + notifiedUser: "알릴 유저" notifiedWebhook: "사용할 Webhook" deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: @@ -2603,11 +2699,11 @@ _moderationLogTypes: deleteDriveFile: "파일 삭제" deleteNote: "노트 삭제" createGlobalAnnouncement: "전역 공지사항 생성" - createUserAnnouncement: "사용자 공지사항 만들기" + createUserAnnouncement: "유저에게 공지사항 만들기" updateGlobalAnnouncement: "모든 공지사항 수정" - updateUserAnnouncement: "사용자 공지사항 수정" + updateUserAnnouncement: "유저의 공지사항 수정" deleteGlobalAnnouncement: "모든 공지사항 삭제" - deleteUserAnnouncement: "사용자 공지사항 삭제" + deleteUserAnnouncement: "유저의 공지사항 삭제" resetPassword: "비밀번호 재설정" suspendRemoteInstance: "리모트 서버를 정지" unsuspendRemoteInstance: "리모트 서버의 정지를 해제" @@ -2635,7 +2731,8 @@ _moderationLogTypes: deleteAccount: "계정을 삭제" deletePage: "페이지를 삭제" deleteFlash: "Play를 삭제" - deleteGalleryPost: "갤러리 포스트를 삭제" + deleteGalleryPost: "갤러리 게시물을 삭제" + deleteChatRoom: "채팅 룸 삭제" updateProxyAccountDescription: "프록시 계정의 설명 업데이트" _fileViewer: title: "파일 상세" @@ -2650,10 +2747,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "제공자를 신뢰할 수 있는 경우에만 설치하십시오." _plugin: title: "이 플러그인을 설치하시겠습니까?" - metaTitle: "플러그인 정보" _theme: title: "이 테마를 설치하시겠습니까?" - metaTitle: "테마 정보" _meta: base: "기본 컬러 스키마" _vendorInfo: @@ -2850,7 +2945,7 @@ _embedCodeGen: _selfXssPrevention: warning: "경고" title: "“이 화면에 뭔가를 붙여넣어라\"는 것은 모두 사기입니다." - description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." + description1: "여기에 무언가를 붙여넣으면 악의적인 유저에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오." description3: "자세한 내용은 여기를 확인해 주세요. {link}" _followRequest: @@ -2883,8 +2978,8 @@ _captcha: title: "CAPTCHA 검증을 실패했습니다." text: "설정이 올바른지 다시 한 번 확인해보세요." _unknown: - title: "CAPTCHA 에러" - text: "알 수 없는 에러가 발생했습니다." + title: "CAPTCHA 오류" + text: "알 수 없는 오류가 발생했습니다." _bootErrors: title: "로딩이 실패함" serverError: "잠시 기다렸다가 다시 로드해도 여전히 문제가 해결되지 않으면 아래 Error ID와 함께 서버 관리자에게 연락해 주세요." @@ -2901,7 +2996,7 @@ _search: searchScopeAll: "전체" searchScopeLocal: "로컬" searchScopeServer: "서버 지정" - searchScopeUser: "사용자 지정" + searchScopeUser: "유저 지정" pleaseEnterServerHost: "서버의 호스트를 입력해 주세요." pleaseSelectUser: "유저를 선택해주세요" serverHostPlaceholder: "예: misskey.example.com" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 06acda44be..455a71f302 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -223,7 +223,6 @@ remove: "ລຶບ" removed: "ລຶບແລ້ວ" resetAreYouSure: "ຣີ​ເຊັດບໍ?" saved: "ບັນທຶກແລ້ວ" -messaging: "ແຊັຕ" upload: "ອັບໂຫຼດ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" fromDrive: "ຈາກ Drive" @@ -233,7 +232,6 @@ uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດແລ້ວ" explore: "ສຳຫຼວດ" messageRead: "ອ່ານແລ້ວ" -startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" nUsersRead: "ອ່ານໂດຍ {n}" agree: "ຍອມຮັບ" termsOfService: "ເງື່ອນໄຂການບໍລິການ" @@ -394,6 +392,12 @@ searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" replies: "ຕອບ​ກັບ" renotes: "Renote" +information: "ກ່ຽວກັບ" +_chat: + invitations: "ເຊີນ" + noHistory: "​ບໍ່​ມີປະຫວັດ" + members: "ສະມາຊິກ" + home: "ໜ້າຫຼັກ" _delivery: stop: "ໂຈະ" _type: diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 320b9d62ba..bc8c86f2c3 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -5,6 +5,7 @@ introMisskey: "Welkom! Misskey is een open source, gedecentraliseerde microblogd poweredByMisskeyDescription: "{name} is één van de services die door het open source platform Misskey wordt geleverd (het wordt ook wel een \"Misskey server genmoemd\")." monthAndDay: "{day} {month}" search: "Zoeken" +reset: "Herstellen" notifications: "Meldingen" username: "Gebruikersnaam" password: "Wachtwoord" @@ -48,6 +49,7 @@ pin: "Vastmaken aan profielpagina" unpin: "Losmaken van profielpagina" copyContent: "Kopiëren inhoud" copyLink: "Kopiëren link" +copyRemoteLink: "Remote-link kopiëren" copyLinkRenote: "" delete: "Verwijderen" deleteAndEdit: "Verwijderen en bewerken" @@ -63,6 +65,7 @@ copyFileId: "Kopieer veld ID" copyFolderId: "Kopieer folder ID" copyProfileUrl: "Kopieer profiel URL" searchUser: "Zoeken een gebruiker" +searchThisUsersNotes: "Notities van deze gebruiker doorzoeken" reply: "Antwoord" loadMore: "Laad meer" showMore: "Toon meer" @@ -129,9 +132,12 @@ emojiPicker: "Emoji kiezer" pinnedEmojisForReactionSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" pinnedEmojisSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" emojiPickerDisplay: "Emoji kiezer weergave" +overwriteFromPinnedEmojisForReaction: "Overschrijven met reactieinstellingen" +overwriteFromPinnedEmojis: "Overschrijven met algemene instellingen" reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, Druk op \"+\" om toe te voegen" rememberNoteVisibility: "Vergeet niet de notitie zichtbaarheidsinstellingen" attachCancel: "Verwijder bijlage" +deleteFile: "Bestand verwijderen" markAsSensitive: "Markeren als NSFW" unmarkAsSensitive: "Geen NSFW" enterFileName: "Invoeren bestandsnaam" @@ -147,6 +153,7 @@ suspendConfirm: "Ben je zeker dat je deze account wil suspenderen?" unsuspendConfirm: "Ben je zeker dat je deze account wil opnieuw aanstellen?" selectList: "Kies een lijst." selectAntenna: "Kies een antenne" +createAntenna: "Antenne aanmaken" selectWidget: "Kies een widget" editWidgets: "Bewerk widgets" editWidgetsExit: "Klaar" @@ -158,6 +165,7 @@ emojiUrl: "URL emoji" addEmoji: "Toevoegen emoji" settingGuide: "Aanbevolen instellingen" cacheRemoteFiles: "Externe bestanden cachen" +cacheRemoteFilesDescription: "Als deze instelling uitgeschakeld is worden bestanden altijd direct van remote servers geladen. Hiermee wordt opslagruimte bespaard, maar doordat er geen thumbnails worden gegenereerd, zal netwerkverkeer toenemen." flagAsBot: "Markeer dit account als een robot." flagAsBotDescription: "Als dit account van een programma wordt beheerd, zet deze vlag aan. Het aanzetten helpt andere ontwikkelaars om bijvoorbeeld onbedoelde feedback loops te doorbreken of om Misskey meer geschikt te maken." flagAsCat: "Markeer dit account als een kat." @@ -168,6 +176,10 @@ autoAcceptFollowed: "Accepteer verzoeken om jezelf te volgen vanzelf als je de v addAccount: "Account toevoegen" loginFailed: "Aanmelding mislukt." showOnRemote: "Toon op de externe instantie." +continueOnRemote: "Verder op remote server" +chooseServerOnMisskeyHub: "Kies een server van de Misskey Hub" +specifyServerHost: "Serverhost uitkiezen" +inputHostName: "Domein invullen" general: "Algemeen" wallpaper: "Achtergrond" setWallpaper: "Achtergrond instellen" @@ -178,6 +190,7 @@ followConfirm: "Weet je zeker dat je {name} wilt volgen?" proxyAccount: "Proxy account" proxyAccountDescription: "Een proxy-account is een account dat onder bepaalde voorwaarden fungeert als externe volger voor gebruikers. Als een gebruiker bijvoorbeeld een externe gebruiker aan de lijst toevoegt, wordt de activiteit van de externe gebruiker niet aan de server geleverd als geen lokale gebruiker die gebruiker volgt, dus het proxy-account volgt in plaats daarvan." host: "Server" +selectSelf: "Mezelf kiezen" selectUser: "Kies een gebruiker" recipient: "Ontvanger" annotation: "Reacties" @@ -192,6 +205,7 @@ perHour: "Per uur" perDay: "Per dag" stopActivityDelivery: "Stop met versturen activiteiten" blockThisInstance: "Blokkeer deze server" +mediaSilenceThisInstance: "Media van deze server dempen" operations: "Verwerkingen" software: "Software" version: "Versie" @@ -211,6 +225,11 @@ clearCachedFiles: "Cache opschonen" clearCachedFilesConfirm: "Weet je zeker dat je alle externe bestanden in de cache wilt verwijderen?" blockedInstances: "Geblokkeerde servers" blockedInstancesDescription: "Maak een lijst van de servers die moeten worden geblokkeerd, gescheiden door regeleinden. Geblokkeerde servers kunnen niet meer communiceren met deze server." +silencedInstancesDescription: "Geef de hostnamen van de servers die je wil dempen op, elk op hun eigen regel. Alle accounts die bij de opgegeven servers horen worden als gedempt behandeld, kunnen alleen maar volgverzoeken maken, en kunnen lokale accounts niet vermelden als ze niet gevolgd worden. Geblokkeerde servers worden hier niet door beïnvloed." +mediaSilencedInstances: "Media-gedempte servers" +mediaSilencedInstancesDescription: "Geef de hostnamen van de servers die je wil media-dempen op, elk op hun eigen regel. Alle accounts die bij de opgegeven servers horen worden als gedempt behandeld, en kunnen geen eigen emojis gebruiken. Geblokkeerde servers worden hier niet door beïnvloed." +federationAllowedHosts: "Servers die mogen federeren " +federationAllowedHostsDescription: "Geef de hostnamen van de servers die mogen federeren op, elk op hun eigen regel." muteAndBlock: "Gedempt en geblokkeerd" mutedUsers: "Gedempte gebruikers" blockedUsers: "Geblokkeerde gebruikers" @@ -255,8 +274,8 @@ removed: "Succesvol verwijderd" removeAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" deleteAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" resetAreYouSure: "Resetten?" +areYouSure: "Weet je het zeker?" saved: "Opgeslagen" -messaging: "Chat" upload: "Uploaden" keepOriginalUploading: "Origineel beeld behouden." keepOriginalUploadingDescription: "Bewaar de originele versie bij het uploaden van afbeeldingen. Indien uitgeschakeld, wordt bij het uploaden een alternatieve versie voor webpublicatie genereert." @@ -269,7 +288,7 @@ uploadFromUrlMayTakeTime: "Het kan even duren voordat het uploaden voltooid is." explore: "Verkennen" messageRead: "Lezen" noMoreHistory: "Er is geen verdere geschiedenis" -startMessaging: "Start een gesprek" +startChat: "Chat starten" nUsersRead: "gelezen door {n}" agreeTo: "Ik stem in met {0}" start: "Aan de slag" @@ -296,12 +315,15 @@ selectFile: "Kies een bestand" selectFiles: "Selecteer bestanden" selectFolder: "Kies een map" selectFolders: "Kies mappen" +fileNotSelected: "Geen bestand geselecteerd" renameFile: "Wijzig bestandsnaam" folderName: "Mapnaam" createFolder: "Map aanmaken" renameFolder: "Map hernoemen" deleteFolder: "Map verwijderen" +folder: "Map" addFile: "Bestand toevoegen" +showFile: "Bestanden weergeven" emptyDrive: "Jouw Drive is leeg." emptyFolder: "Deze map is leeg" unableToDelete: "Kan niet worden verwijderd" @@ -357,8 +379,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "Inschakelen hCaptcha" hcaptchaSiteKey: "Site sleutel" hcaptchaSecretKey: "Geheime sleutel" +mcaptcha: "mCaptcha" +enableMcaptcha: "mCaptcha activeren" mcaptchaSiteKey: "Site sleutel" mcaptchaSecretKey: "Geheime sleutel" +mcaptchaInstanceUrl: "mCaptcha server-URL" recaptcha: "reCAPTCHA" enableRecaptcha: "Inschakelen reCAPTCHA" recaptchaSiteKey: "Site sleutel" @@ -373,6 +398,7 @@ name: "Naam" antennaSource: "Bron antenne" antennaKeywords: "Sleutelwoorden" antennaExcludeKeywords: "Blokkeerwoorden" +antennaExcludeBots: "Bot-accounts uitsluiten" withReplies: "Antwoorden toevoegen" connectedTo: "De volgende accounts zijn verbonden" notesAndReplies: "Berichten en reacties" @@ -423,7 +449,13 @@ retype: "Opnieuw invoeren" noteOf: "Notitie van {user}" quoteAttached: "Citaat" quoteQuestion: "Toevoegen als citaat?" +signinOrContinueOnRemote: "Ga naar je eigen instantie of registreer je/log in op deze server om door te gaan." invitations: "Uitnodigen" +menuStyle: "Menustijl" +style: "Stijl" +drawer: "Lade" +popup: "Pop-up" +showReactionsCount: "Zie het aantal reacties op notities" dashboard: "Overzicht" local: "Lokaal" remote: "Remote" @@ -439,16 +471,41 @@ numberOfDays: "Aantal dagen" hideThisNote: "Verberg deze notitie" showFeaturedNotesInTimeline: "Laat featured notities in tijdlijn zien" sound: "Geluid" +notUseSound: "Geluid uitschakelen" +useSoundOnlyWhenActive: "Geluid alleen inschakelen wanneer Misskey actief is" +uiInspector: "UI-inspecteur" +unsetUserAvatar: "Avatar verwijderen" +unsetUserAvatarConfirm: "Weet je zeker dat je je avatar wil verwijderen?" +unsetUserBanner: "Banner verwijderen" +unsetUserBannerConfirm: "Weet je zeker dat je je banner wil verwijderen?" +expandTweet: "Notitie uitklappen" +adminPermission: "Administratorrechten" smtpHost: "Server" smtpUser: "Gebruikersnaam" smtpPass: "Wachtwoord" +wordMuteDescription: "Minimaliseert notities die het gespecificeerde woord of zin bevatten. Geminimaliseerde notities kunnen worden weergegeven door er op te klikken." +hardWordMute: "Harde woorddemping" +showMutedWord: "Gedempte woorden weergeven" +hardWordMuteDescription: "Verbert notities die het gespecificeerde woord of zin bevatten. In tegenstelling tot woorddemping wordt de notitie volledig verborgen." +userSaysSomethingAbout: "{name} zei iets over '{word}'" +copiedToClipboard: "Naar het klembord gekopieerd" +theKeywordWhenSearchingForCustomEmoji: "Dit is het keyword dat gebruikt wordt bij het zoeken naar eigen emojis." +fillAbuseReportDescription: "Vul s.v.p. de details in over deze melding. Geef, als het over een specifieke notitie gaat, ook de URL op." +reloadToApplySetting: "Deze instelling gaat pas in nadat de pagina herladen is. Nu herladen?" clearCache: "Cache opschonen" info: "Over" user: "Gebruikers" +noInquiryUrlWarning: "Contact-URL niet opgegeven" muteThread: "Discussies dempen " unmuteThread: "Dempen van discussie ongedaan maken" +followingVisibility: "Zichtbaarheid van gevolgden" +followersVisibility: "Zichtbaarheid van volgers" +incorrectTotp: "Het eenmalige wachtwoord is incorrect of verlopen" hide: "Verbergen" searchByGoogle: "Zoeken" +threeMonths: "3 maanden" +oneYear: "1 jaar" +threeDays: "3 dagen" cropImage: "Afbeelding bijsnijden" cropImageAsk: "Bijsnijdengevraagd" file: "Bestanden" @@ -459,9 +516,33 @@ pushNotificationAlreadySubscribed: "Pushberichtrn al ingeschakeld" windowMaximize: "Maximaliseren" windowRestore: "Herstellen" loggedInAsBot: "Momenteel als bot ingelogd" +correspondingSourceIsAvailable: "De bijbehorende broncode is beschikbaar bij {anchor}" +invalidParamErrorDescription: "De aanvraagparameters zijn ongeldig. Dit komt meestal door een bug, maar kan ook omdat de invoer te lang is of iets dergelijks." +collapseRenotes: "Renotes die je al gezien hebt, inklappen" +collapseRenotesDescription: "Klapt notities in waar je al op gereageerd hebt of die je al gerenotet hebt." +prohibitedWords: "Verboden woorden" +prohibitedWordsDescription: "Activeert een foutmelding als er geprobeerd wordt een notitie met de ingestelde woorden te plaatsen. Meerdere woorden kunnen worden ingesteld, elk op hun eigen regel." +hiddenTags: "Verborgen hashtags" +hiddenTagsDescription: "Selecteer tags die niet worden weergegeven in de trends. Meerdere tags kunnen worden geregistreerd, elk op hun eigen regel." +enableStatsForFederatedInstances: "Statistieken van remote servers ontvangen" +limitWidthOfReaction: "Limiteert de maximale breedte van reacties en geef ze verkleind weer" +audio: "Audio" +audioFiles: "Audio" +archived: "Gearchiveerd" +unarchive: "Dearchiveren" +lookupConfirm: "Weet je zeker dat je dit wil opzoeken?" +openTagPageConfirm: "Wil je deze hashtagpagina openen?" +specifyHost: "Specificeer host" icon: "Avatar" replies: "Antwoord" renotes: "Herdelen" +followingOrFollower: "Gevolgd of volger" +confirmShowRepliesAll: "Dit is een onomkeerbare operatie. Weet je zeker dat reacties op anderen van iedereen die je volgt, wil weergeven in je tijdlijn?" +information: "Over" +_chat: + invitations: "Uitnodigen" + members: "Leden" + home: "Startpagina" _delivery: stop: "Opgeschort" _type: diff --git a/locales/no-NO.yml b/locales/no-NO.yml index c1b145cab4..44b2cd2704 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -299,8 +299,6 @@ text: "Tekst" next: "Neste" retype: "Gjenta" quoteAttached: "Sitat" -noMessagesYet: "Ingen meldinger ennå" -newMessageExists: "Det er nye meldinger" onlyOneFileCanBeAttached: "Du kan bare legge ved én fil i en melding" invitations: "Inviter" available: "Tilgjengelig" @@ -463,6 +461,12 @@ icon: "Avatar" replies: "Svar" renotes: "Renote" surrender: "Avbryt" +information: "Informasjon" +_chat: + invitations: "Inviter" + members: "Medlemmer" + home: "Hjem" + send: "Send" _delivery: stop: "Suspendert" _initialAccountSetting: diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index a4538ee7b2..46f8939137 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -269,7 +269,6 @@ deleteAreYouSure: "Czy na pewno chcesz usunąć „{x}”?" resetAreYouSure: "Czy na pewno chcesz zresetować?" areYouSure: "Na pewno?" saved: "Zapisano" -messaging: "Wiadomości" upload: "Wyślij" keepOriginalUploading: "Zachowaj oryginalny obraz" keepOriginalUploadingDescription: "Zapisuje oryginalnie przesłany obraz w niezmienionej postaci. Jeśli ta opcja jest wyłączona, po przesłaniu zostanie wygenerowana wersja do wyświetlenia w Internecie." @@ -282,7 +281,6 @@ uploadFromUrlMayTakeTime: "Wysyłanie może chwilę potrwać." explore: "Eksploruj" messageRead: "Przeczytano" noMoreHistory: "Nie ma dalszej historii" -startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" agree: "Zatwierdź" @@ -466,8 +464,6 @@ retype: "Wprowadź ponownie" noteOf: "Wpisy {user}" quoteAttached: "Zacytowano" quoteQuestion: "Czy na pewno chcesz umieścić cytat?" -noMessagesYet: "Nie napisano jeszcze wiadomości" -newMessageExists: "Masz nową wiadomość" onlyOneFileCanBeAttached: "Możesz załączyć tylko jeden plik do wiadomości" signinRequired: "Proszę się zalogować" invitations: "Zaproś" @@ -1045,6 +1041,13 @@ lastNDays: "W ciągu ostatnich {n} dni" surrender: "Odrzuć" gameRetry: "Spróbuj ponownie" postForm: "Formularz tworzenia wpisu" +information: "Informacje" +_chat: + invitations: "Zaproś" + noHistory: "Brak historii" + members: "Członkowie" + home: "Strona główna" + send: "Wyślij" _delivery: stop: "Zawieszono" _type: @@ -1209,7 +1212,6 @@ _theme: header: "Nagłówek" navBg: "Tło paska bocznego" navFg: "Tekst paska bocznego" - navHoverFg: "Tekst paska bocznego (zbliżenie)" navActive: "Tekst paska bocznego (aktywny)" navIndicator: "Wskaźnik paska bocznego" link: "Odnośnik" @@ -1232,11 +1234,8 @@ _theme: buttonHoverBg: "Tło przycisku (po najechaniu)" inputBorder: "Obramowanie pola wejścia" driveFolderBg: "Tło folderu na dysku" - wallpaperOverlay: "Nakładka tapety" badge: "Odznaka" messageBg: "Tło czatu" - accentDarken: "Akcent (ciemniejszy)" - accentLighten: "Akcent (jaśniejszy)" fgHighlighted: "Wyróżniony tekst" _sfx: note: "Wpisy" @@ -1301,6 +1300,7 @@ _permissions: "write:gallery": "Edytuj swoją galerię" "read:gallery-likes": "Wyświetlanie listy polubionych postów w galerii" "write:gallery-likes": "Edytowanie listy polubionych postów w galerii" + "write:chat": "Tworzenie lub usuwanie wiadomości czatu" _auth: shareAccessTitle: "Przyznawanie uprawnień aplikacji" shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 4511ce662f..e899685d9f 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -287,7 +287,6 @@ deleteAreYouSure: "Deseja excluir \"{x}\"?" resetAreYouSure: "Deseja reiniciar?" areYouSure: "Tem certeza?" saved: "Salvo" -messaging: "Chat" upload: "Fazer upload" keepOriginalUploading: "Manter a imagem original" keepOriginalUploadingDescription: "Ao fazer o upload de uma imagem, ela será mantida em sua versão original. Caso desative esta opção, o navegador irá gerar uma versão da imagem otimizada para publicação na web durante o upload." @@ -300,7 +299,6 @@ uploadFromUrlMayTakeTime: "Pode levar algum tempo para que o upload seja conclu explore: "Explorar" messageRead: "Lida" noMoreHistory: "Não existe histórico anterior" -startMessaging: "Iniciar conversação" nUsersRead: "{n} pessoas leram" agreeTo: "Eu concordo com {0}" agree: "Concordar" @@ -489,8 +487,6 @@ noteOf: "Publicação de {user}" quoteAttached: "Com citação" quoteQuestion: "Anexar como citação?" attachAsFileQuestion: "O texto na área de transferência é muito longo. Você gostaria de anexá-lo como um arquivo de texto?" -noMessagesYet: "Sem conversas até o momento" -newMessageExists: "Há uma nova mensagem" onlyOneFileCanBeAttached: "Apenas um arquivo pode ser anexado a uma mensagem" signinRequired: "É necessário se inscrever ou fazer login antes de continuar" signinOrContinueOnRemote: "Para continuar, você precisa mover o seu servidor ou entrar/cadastrar-se nesse servidor." @@ -1302,6 +1298,13 @@ pleaseSelectAccount: "Selecione uma conta" availableRoles: "Cargos disponíveis" acknowledgeNotesAndEnable: "Ative após compreender as precauções." postForm: "Campo de postagem" +information: "Informações" +_chat: + invitations: "Convidar" + noHistory: "Ainda não há histórico" + members: "Membros" + home: "Início" + send: "Enviar" _settings: webhook: "Webhook" _accountSettings: @@ -1994,7 +1997,6 @@ _theme: header: "Cabeçalho" navBg: "Plano de fundo da barra lateral" navFg: "Texto da barra lateral" - navHoverFg: "Texto da coluna lateral (Selecionado)" navActive: "Texto da coluna lateral (Ativa)" navIndicator: "Indicador da coluna lateral" link: "Link" @@ -2017,11 +2019,8 @@ _theme: buttonHoverBg: "Plano de fundo de botão (Selecionado)" inputBorder: "Borda de campo digitável" driveFolderBg: "Plano de fundo da pasta no Drive" - wallpaperOverlay: "Sobreposição do papel de parede." badge: "Emblema" messageBg: "Plano de fundo do chat" - accentDarken: "Cor de destaque (Escurecida)" - accentLighten: "Cor de destaque (Esclarecida)" fgHighlighted: "Texto Destacado" _sfx: note: "Posts" @@ -2174,6 +2173,7 @@ _permissions: "read:clip-favorite": "Ver Clipes favoritados" "read:federation": "Ver dados de federação" "write:report-abuse": "Reportar violação" + "write:chat": "Compor ou editar mensagens de chat" _auth: shareAccessTitle: "Conceder permissões do aplicativo" shareAccess: "Você gostaria de autorizar \"{name}\" para acessar essa conta?" @@ -2598,10 +2598,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Tenha certeza de que o distribuidor desse recurso é confiável antes da instalação." _plugin: title: "Deseja instalar esse plugin?" - metaTitle: "Informações do plugin" _theme: title: "Deseja instalar esse tema?" - metaTitle: "Informações do tema" _meta: base: "Paleta de cores base" _vendorInfo: diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 0d43e174a7..5e0d3f221f 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -254,7 +254,6 @@ removeAreYouSure: "Ești sigur că vrei să înlături {x}?" deleteAreYouSure: "Ești sigur că vrei să ștergi {x}?" resetAreYouSure: "Sigur vrei să resetezi?" saved: "Salvat" -messaging: "Chat" upload: "Încarcă" keepOriginalUploading: "Păstrează imaginea originală" keepOriginalUploadingDescription: "Salvează imaginea originala încărcată fără modificări. Dacă e oprită, o versiune pentru afișarea pe web va fi generată la încărcare." @@ -267,7 +266,6 @@ uploadFromUrlMayTakeTime: "S-ar putea să ia puțin până se finalizează înc explore: "Explorează" messageRead: "Citit" noMoreHistory: "Nu există mai mult istoric" -startMessaging: "Începe un chat nou" nUsersRead: "citit de {n}" agreeTo: "Sunt de acord cu {0}" start: "Să începem" @@ -429,8 +427,6 @@ retype: "Introdu din nou" noteOf: "Notă de {user}" quoteAttached: "Citat" quoteQuestion: "Vrei să adaugi ca citat?" -noMessagesYet: "Niciun mesaj încă" -newMessageExists: "Ai mesaje noi" onlyOneFileCanBeAttached: "Poți atașa un singur fișier la un mesaj" signinRequired: "Te rog autentifică-te" invitations: "Invită" @@ -646,6 +642,13 @@ show: "Arată" icon: "Avatar" replies: "Răspunde" renotes: "Re-notează" +information: "Despre" +_chat: + invitations: "Invită" + noHistory: "Nu există istoric" + members: "Membri" + home: "Acasă" + send: "Trimite" _delivery: stop: "Suspendat" _type: diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 2a200deac0..e81af534e7 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -282,7 +282,6 @@ deleteAreYouSure: "Хотите удалить «{x}»?" resetAreYouSure: "На самом деле сбросить?" areYouSure: "Вы уверены?" saved: "Сохранено" -messaging: "Сообщения" upload: "Загрузить" keepOriginalUploading: "Сохранить исходное изображение" keepOriginalUploadingDescription: "Сохраняет исходную версию при загрузке изображений. Если выключить, то при загрузке браузер генерирует изображение для публикации." @@ -295,7 +294,6 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото explore: "Обзор" messageRead: "Прочитали" noMoreHistory: "История закончилась" -startMessaging: "Начать общение" nUsersRead: "Прочитали {n}" agreeTo: "Я соглашаюсь с {0}" agree: "Согласен" @@ -482,8 +480,6 @@ noteOf: "Что пишет {user}" quoteAttached: "Цитата" quoteQuestion: "Хотите добавить цитату?" attachAsFileQuestion: "Текста в буфере обмена слишком много. Прикрепить как текстовый файл?" -noMessagesYet: "Пока ни одного сообщения" -newMessageExists: "Новое сообщение" onlyOneFileCanBeAttached: "К сообщению можно прикрепить только один файл" signinRequired: "Пожалуйста, войдите" signinOrContinueOnRemote: "Чтобы продолжить, вам необходимо войти в аккаунт на своём сервере или зарегистрироваться / войти в аккаунт на этом." @@ -1182,6 +1178,13 @@ alwaysConfirmFollow: "Всегда подтверждать подписку" inquiry: "Связаться" messageToFollower: "Сообщение подписчикам" postForm: "Форма отправки" +information: "Описание" +_chat: + invitations: "Пригласить" + noHistory: "История пока пуста" + members: "Участники" + home: "Главная" + send: "Отправить" _settings: webhook: "Вебхук" _delivery: @@ -1686,7 +1689,6 @@ _theme: header: "Заголовок" navBg: "Фон боковой панели" navFg: "Текст на боковой панели" - navHoverFg: "Текст на боковой панели (под указателем)" navActive: "Текст на боковой панели (активирован)" navIndicator: "Индикатор на боковой панели" link: "Ссылка" @@ -1709,11 +1711,8 @@ _theme: buttonHoverBg: "Текст кнопки" inputBorder: "Рамка поля ввода" driveFolderBg: "Фон папки «Диска»" - wallpaperOverlay: "Слой обоев" badge: "Значок" messageBg: "Фон беседы" - accentDarken: "Фон (затемнённый)" - accentLighten: "Фон (осветлённый)" fgHighlighted: "Подсвеченный текст" _sfx: note: "Заметки" @@ -1802,6 +1801,7 @@ _permissions: "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" "write:admin:reset-password": "Сбросить пароль пользователю" + "write:chat": "Писать и удалять сообщения" _auth: shareAccessTitle: "Разрешения для приложений" shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 7ae264081f..1638fd293e 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -242,7 +242,6 @@ removeAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" deleteAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" resetAreYouSure: "Naozaj resetovať?" saved: "Uložené" -messaging: "Chat" upload: "Nahrať súbor" keepOriginalUploading: "Zachovať pôvodný obrázok" keepOriginalUploadingDescription: "Uloží pôvodný obrázok ako je. Ak je vypnuté, verzia pre web sa vygeneruje pri nahratí." @@ -255,7 +254,6 @@ uploadFromUrlMayTakeTime: "Nahrávanie môže nejaký čas trvať." explore: "Objavovať" messageRead: "Prečítané" noMoreHistory: "To je všetko" -startMessaging: "Začať chat" nUsersRead: "prečítané {n} používateľmi" agreeTo: "Súhlasím s {0}" agreeBelow: "Súhlasím s nasledovným" @@ -428,8 +426,6 @@ retype: "Zadajte znovu" noteOf: "Poznámky používateľa {user}" quoteAttached: "Citované" quoteQuestion: "Pripojiť ako citát?" -noMessagesYet: "Zatiaľ žiadne správy" -newMessageExists: "Máte novú správu" onlyOneFileCanBeAttached: "Ku správe môžete priložiť len jeden súbor" signinRequired: "Prihláste sa, prosím!" invitations: "Pozvať" @@ -918,6 +914,13 @@ sourceCode: "Zdrojový kód" flip: "Preklopiť" lastNDays: "Posledných {n} dní" postForm: "Napísať poznámku" +information: "Informácie" +_chat: + invitations: "Pozvať" + noHistory: "Žiadna história" + members: "Členovia" + home: "Domov" + send: "Poslať" _delivery: stop: "Zmrazené" _type: @@ -1086,7 +1089,6 @@ _theme: header: "Hlavička" navBg: "Pozadie bočného panela" navFg: "Text bočného panela" - navHoverFg: "Text bočného panela (pod kurzorom)" navActive: "Text bočného panela (aktívny)" navIndicator: "Indikátor bočného panela" link: "Odkaz" @@ -1109,11 +1111,8 @@ _theme: buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" inputBorder: "Okraj vstupného poľa" driveFolderBg: "Pozadie priečinu disku" - wallpaperOverlay: "Vrstvenie pozadia" badge: "Odznak" messageBg: "Pozadie chatu" - accentDarken: "Akcent (stmavené)" - accentLighten: "Akcent (zosvetlené)" fgHighlighted: "Zvýraznený text" _sfx: note: "Poznámky" @@ -1177,6 +1176,7 @@ _permissions: "write:gallery": "Upravovať vašu galériu" "read:gallery-likes": "Vidieť zoznam obľúbených príspevkov z galérie" "write:gallery-likes": "Upraviť zoznam obľúbených príspevov z galérie" + "write:chat": "Písať alebo odstraňovať správy v chate" _auth: shareAccess: "Prajete si povoliť \"{name}\", aby mal prístup k tomuto účtu?" shareAccessAsk: "Naozaj chcete povoliť tejto aplikácii prístup k tomuto účtu?" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 91413e3bc7..ceb02ffc4c 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -249,7 +249,6 @@ removeAreYouSure: "Är du säker att du vill radera \"{x}\"?" deleteAreYouSure: "Är du säker att du vill radera \"{x}\"?" resetAreYouSure: "Vill du återställa?" saved: "Sparad" -messaging: "Chatt" upload: "Ladda upp" keepOriginalUploading: "Behåll originalbild" keepOriginalUploadingDescription: "Sparar den originellt uppladdade bilden i sitt i befintliga skick. Om avstängd, kommer en webbversion bli genererad vid uppladdning." @@ -262,7 +261,6 @@ uploadFromUrlMayTakeTime: "Det kan ta tid tills att uppladdningen blir klar." explore: "Utforska" messageRead: "Läs" noMoreHistory: "Det finns ingen mer historik" -startMessaging: "Starta en chatt" nUsersRead: "läst av {n}" agreeTo: "Jag accepterar {0}" agree: "Överens" @@ -394,7 +392,6 @@ text: "Text" enable: "Aktivera" next: "Nästa" retype: "Ange igen" -noMessagesYet: "Inga meddelanden än" invitations: "Inbjudan" invitationCode: "Inbjudningskod" available: "Tillgängligt" @@ -562,6 +559,12 @@ inquiry: "Kontakt" tryAgain: "Försök igen senare" signinWithPasskey: "Logga in med nyckel" unknownWebAuthnKey: "Okänd nyckel" +information: "Om" +_chat: + invitations: "Inbjudan" + members: "Medlemmar" + home: "Hem" + send: "Skicka" _delivery: stop: "Suspenderad" _type: diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 939b0d7269..06f68c85fe 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -287,7 +287,6 @@ deleteAreYouSure: "ต้องการลบ “{x}” ใช่ไหม?" resetAreYouSure: "รีเซ็ตเลยไหม?" areYouSure: "แน่ใจแล้วใช่ไหมคะ?" saved: "บันทึกแล้ว" -messaging: "แชท" upload: "อัปโหลด" keepOriginalUploading: "เก็บภาพต้นฉบับ" keepOriginalUploadingDescription: "เก็บภาพต้นฉบับไว้เมื่ออัปโหลดภาพ หากปิด รูปภาพสำหรับการเผยแพร่ทางเว็บจะถูกสร้างขึ้นในเบราว์เซอร์เมื่อทำการอัปโหลด" @@ -300,7 +299,6 @@ uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เ explore: "สำรวจ" messageRead: "อ่านแล้ว" noMoreHistory: "ไม่มีประวัติเพิ่มเติม" -startMessaging: "เริ่มการสนทนา" nUsersRead: "อ่านโดย {n}" agreeTo: "ฉันยอมรับ {0}" agree: "ยอมรับ" @@ -489,8 +487,6 @@ noteOf: "โน้ตของ {user}" quoteAttached: "อ้างอิง" quoteQuestion: "ต้องการที่จะแนบมันเพื่ออ้างอิงใช่ไหม?" attachAsFileQuestion: "ข้อความในคลิปบอร์ดยาวเกินไป คุณต้องการแนบเป็นไฟล์ข้อความหรือไม่?" -noMessagesYet: "ยังไม่มีข้อความ" -newMessageExists: "คุณมีข้อความใหม่" onlyOneFileCanBeAttached: "สามารถแนบไฟล์ได้เพียงไฟล์เดียวต่อ 1 ข้อความ" signinRequired: "ก่อนดำเนินการต่อ กรุณาลงทะเบียนหรือเข้าสู่ระบบ" signinOrContinueOnRemote: "เพื่อดำเนินการต่อได้ คุณต้องไปที่เซิร์ฟเวอร์ที่คุณใช้งานอยู่ หรือลงทะเบียน/เข้าสู่ระบบเซิร์ฟเวอร์นี้" @@ -1293,6 +1289,13 @@ prohibitedWordsForNameOfUserDescription: "หากมีสตริงใด yourNameContainsProhibitedWords: "ชื่อของคุณนั้นมีคำที่ต้องห้าม" yourNameContainsProhibitedWordsDescription: "ถ้าหากคุณต้องการใช้ชื่อนี้ กรุณาติดต่อผู้ดูแลระบบของเซิร์ฟเวอร์นะค่ะ" postForm: "แบบฟอร์มการโพสต์" +information: "เกี่ยวกับ" +_chat: + invitations: "คำเชิญ" + noHistory: "ไม่มีประวัติ" + members: "สมาชิก" + home: "หน้าหลัก" + send: "ส่ง" _settings: webhook: "Webhook" _abuseUserReport: @@ -1971,7 +1974,6 @@ _theme: header: "ส่วนหัว" navBg: "พื้นหลังแถบด้านข้าง" navFg: "ข้อความแถบด้านข้าง" - navHoverFg: "ข้อความแถบด้านข้าง (โฮเวอร์)" navActive: "ข้อความแถบด้านข้าง (ใช้งานอยู่)" navIndicator: "ตัวระบุแถบด้านข้าง" link: "ลิงก์" @@ -1994,11 +1996,8 @@ _theme: buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" inputBorder: "เส้นขอบของช่องป้อนข้อมูล" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" - wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" badge: "ตรา" messageBg: "พื้นหลังแชท" - accentDarken: "สีหลัก (มืด)" - accentLighten: "สีหลัก (สว่าง)" fgHighlighted: "ข้อความที่ไฮไลต์" _sfx: note: "โน้ต" @@ -2151,6 +2150,7 @@ _permissions: "read:clip-favorite": "ดูคลิปที่ถูกใจ" "read:federation": "รับข้อมูลเกี่ยวกับสหพันธ์" "write:report-abuse": "รายงานการละเมิด" + "write:chat": "เขียนหรือลบข้อความแชท" _auth: shareAccessTitle: "การให้สิทธิ์แอปพลิเคชัน" shareAccess: "คุณต้องการอนุญาตให้ \"{name}\" เข้าถึงบัญชีนี้เลยมั้ย?" @@ -2572,10 +2572,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "โปรดตรวจสอบให้แน่ใจว่าแหล่งแจกหน่ายมีความน่าเชื่อถือก่อนทำการติดตั้ง" _plugin: title: "ต้องการติดตั้งปลั๊กอินนี้ใช่ไหม?" - metaTitle: "ข้อมูลส่วนเสริม" _theme: title: "ต้องการติดตั้งธีมนี้ใช่ไหม?" - metaTitle: "ข้อมูลธีม" _meta: base: "โทนสีพื้นฐาน" _vendorInfo: diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index e99aa64254..1756e8dbeb 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -261,7 +261,6 @@ removeAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" deleteAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" resetAreYouSure: "Sıfırlansın mı?" saved: "Kaydedildi" -messaging: "Mesajlar" upload: "Yükle" keepOriginalUploading: "Orijinal görseli koru" keepOriginalUploadingDescription: "Orijinal olarak yüklenen görüntüyü olduğu gibi kaydeder. Kapatılırsa, yükleme sırasında web'de görüntülenecek bir sürüm oluşturulur." @@ -274,7 +273,6 @@ uploadFromUrlMayTakeTime: "Yüklemenin tamamlanması biraz süre alabilir." explore: "Keşfet" messageRead: "Okundu" noMoreHistory: "Bundan öncesi yok" -startMessaging: "Yeni bir sohbet başlat" nUsersRead: "{n} kişi okudu" agreeTo: "Kabul Ediyorum: {0}" agree: "Kabul Et" @@ -351,7 +349,6 @@ pinnedNotes: "Sabitlenen" manageAntennas: "Anten ayarları" userList: "Listeler" resetPassword: "Şifre sıfırlama" -noMessagesYet: "Şimdilik mesaj yok" details: "Detaylar" deck: "Güverte" smtpHost: "Sağlayıcı" @@ -378,6 +375,8 @@ addMemo: "Kısa not ekle" icon: "Avatar" replies: "yanıt" renotes: "vazgeçme" +_chat: + home: "Ana sayfa" _delivery: stop: "Askıya alınmış" _type: diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 3e068127f5..6a970fadfb 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -246,7 +246,6 @@ removeAreYouSure: "Ви впевнені, що хочете видалити \"{ deleteAreYouSure: "Ви впевнені, що хочете видалити \"{x}\"?" resetAreYouSure: "Справді скинути?" saved: "Збережено" -messaging: "Чати" upload: "Завантажити" keepOriginalUploading: "Зберегти оригінальне зображення" keepOriginalUploadingDescription: "Зберігає початково завантажене зображення як є. Якщо вимкнено, версія для відображення в Інтернеті буде створена під час завантаження." @@ -259,7 +258,6 @@ uploadFromUrlMayTakeTime: "Завантаження може зайняти де explore: "Огляд" messageRead: "Прочитано" noMoreHistory: "Подальшої історії немає" -startMessaging: "Розпочати діалог" nUsersRead: "Прочитали {n}" agreeTo: "Я погоджуюсь з {0}" agreeBelow: "Я погоджуюся з наведеним нижче" @@ -427,8 +425,6 @@ retype: "Введіть ще раз" noteOf: "Нотатка {user}" quoteAttached: "Цитата" quoteQuestion: "Ви хочете додати цитату?" -noMessagesYet: "Ще немає повідомлень" -newMessageExists: "Є нові повідомлення" onlyOneFileCanBeAttached: "До повідомлення можна вкласти лише один файл" signinRequired: "Будь ласка, авторизуйтесь" invitations: "Запрошення" @@ -910,6 +906,13 @@ sourceCode: "Вихідний код" flip: "Перевернути" lastNDays: "Останні {n} днів" postForm: "Створення нотатки" +information: "Інформація" +_chat: + invitations: "Запросити" + noHistory: "Історія порожня" + members: "Учасники" + home: "Домівка" + send: "Відправити" _delivery: stop: "Призупинено" _type: @@ -1280,7 +1283,6 @@ _theme: header: "Заголовок" navBg: "Фон бокової панелі" navFg: "Текст бокової панелі" - navHoverFg: "Текст бокової панелі (під курсором)" navActive: "Текст бокової панелі (активне)" navIndicator: "Індикатор бокової панелі" link: "Посилання" @@ -1303,11 +1305,8 @@ _theme: buttonHoverBg: "Фон кнопки (при наведенні)" inputBorder: "Край поля вводу" driveFolderBg: "Фон папки на диску" - wallpaperOverlay: "Накладання шпалер" badge: "Значок" messageBg: "Фон переписки" - accentDarken: "Акцент (Затемлений)" - accentLighten: "Акцент (Освітлений)" fgHighlighted: "Виділений текст" _sfx: note: "Нотатки" @@ -1366,6 +1365,7 @@ _permissions: "read:channels": "Переглядати канали" "write:channels": "Змінювати канали" "read:gallery": "Перегляд галереї" + "write:chat": "Створювати та видаляти повідомлення" _auth: shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?" shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index c70802e417..8289a6d60c 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -257,7 +257,6 @@ removeAreYouSure: "“{x}”ni olib tashlamoqchi ekanligingizga ishonchingiz kom deleteAreYouSure: "“{x}”ni chindan ham yo'q qilmoqchimisiz?" resetAreYouSure: "Haqiqatan ham qayta tiklansinmi?" saved: "Saqlandi" -messaging: "Suhbat" upload: "Yuklash" keepOriginalUploading: "Asl rasmni saqlang" keepOriginalUploadingDescription: "Rasmlarni yuklashda asl nusxasini saqlaydi. Agar o'chirilgan bo'lsa, brauzer yuklangandan keyin nashr qilish uchun rasm yaratadi." @@ -270,7 +269,6 @@ uploadFromUrlMayTakeTime: "Yuklash tugallanishi uchun biroz vaqt ketishi mumkin. explore: "Ko'rib chiqish" messageRead: "O‘qildi" noMoreHistory: "Buning ortida hech qanday hikoya yo'q" -startMessaging: "Yangi suhbatni boshlash" nUsersRead: "{n} tomonidan o'qildi" agreeTo: "Men {0} ga roziman" agree: "Rozi bo'lish" @@ -445,8 +443,6 @@ retype: "Qayta kiriting" noteOf: "{user} tomonidan joylandi\n" quoteAttached: "Iqtibos" quoteQuestion: "Iqtibos sifatida qo'shilsinmi?" -noMessagesYet: "Bu yerda xabarlar yo'q" -newMessageExists: "Yangi xabarlar bor" onlyOneFileCanBeAttached: "Faqat bitta faylni biriktirish mumkin" signinRequired: "Davom etishdan oldin ro'yhatdan o'tishingiz yoki tizimga kirishingiz kerak" invitations: "Taklif qilish" @@ -841,6 +837,13 @@ icon: "Avatar" replies: "Javob berish" renotes: "Qayta qayd etish" flip: "Teskari" +information: "Haqida" +_chat: + invitations: "Taklif qilish" + noHistory: "Tarix yo'q" + members: "A'zolar" + home: "Bosh sahifa" + send: "Yuborish" _delivery: stop: "To'xtatilgan" _type: @@ -904,8 +907,6 @@ _theme: mention: "Murojat" renote: "Qayta qayd etish" divider: "Ajratrmoq" - accentDarken: "Urg'u (Qoraytirilgan)" - accentLighten: "Urg'u (Yoritilgan)" fgHighlighted: "Belgilangan matn" _sfx: note: "Qaydlar" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index d2107eaba4..16917ebf06 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -264,7 +264,6 @@ deleteAreYouSure: "Bạn có chắc muốn xóa \"{x}\"?" resetAreYouSure: "Bạn có chắc muốn đặt lại?" areYouSure: "Bạn chắc chứ?" saved: "Đã lưu" -messaging: "Trò chuyện" upload: "Tải lên" keepOriginalUploading: "Giữ hình ảnh gốc" keepOriginalUploadingDescription: "Giữ nguyên như hình ảnh được tải lên ban đầu. Nếu tắt, một phiên bản để hiển thị trên web sẽ được tạo khi tải lên." @@ -277,7 +276,6 @@ uploadFromUrlMayTakeTime: "Sẽ mất một khoảng thời gian để tải lê explore: "Khám phá" messageRead: "Đã đọc" noMoreHistory: "Không còn gì để đọc" -startMessaging: "Bắt đầu trò chuyện" nUsersRead: "đọc bởi {n}" agreeTo: "Tôi đồng ý {0}" agree: "Đồng ý" @@ -463,8 +461,6 @@ noteOf: "Tút của {user}" quoteAttached: "Trích dẫn" quoteQuestion: "Trích dẫn lại?" attachAsFileQuestion: "Văn bản ở trong bộ nhớ tạm rất dài. Bạn có muốn đăng nó dưới dạng một tệp văn bản không?" -noMessagesYet: "Chưa có tin nhắn" -newMessageExists: "Bạn có tin nhắn mới" onlyOneFileCanBeAttached: "Bạn chỉ có thể đính kèm một tập tin" signinRequired: "Vui lòng đăng nhập" invitations: "Mời" @@ -1120,6 +1116,13 @@ cwNotationRequired: "Nếu \"Ẩn nội dung\" được bật thì cần phải lastNDays: "{n} ngày trước" surrender: "Từ chối" postForm: "Mẫu đăng" +information: "Giới thiệu" +_chat: + invitations: "Mời" + noHistory: "Không có dữ liệu" + members: "Thành viên" + home: "Trang chính" + send: "Gửi" _delivery: stop: "Đã vô hiệu hóa" _type: @@ -1527,7 +1530,6 @@ _theme: header: "Ảnh bìa" navBg: "Nền thanh bên" navFg: "Chữ thanh bên" - navHoverFg: "Chữ thanh bên (Khi chạm)" navActive: "Chữ thanh bên (Khi chọn)" navIndicator: "Chỉ báo thanh bên" link: "Đường dẫn" @@ -1550,11 +1552,8 @@ _theme: buttonHoverBg: "Nền nút (Chạm)" inputBorder: "Đường viền khung soạn thảo" driveFolderBg: "Nền thư mục Ổ đĩa" - wallpaperOverlay: "Lớp phủ hình nền" badge: "Huy hiệu" messageBg: "Nền chat" - accentDarken: "Màu phụ (Tối)" - accentLighten: "Màu phụ (Sáng)" fgHighlighted: "Chữ nổi bật" _sfx: note: "Tút" @@ -1629,6 +1628,7 @@ _permissions: "write:gallery": "Sửa kho ảnh của tôi" "read:gallery-likes": "Xem danh sách các tút đã thích trong thư viện của tôi" "write:gallery-likes": "Sửa danh sách các tút đã thích trong thư viện của tôi" + "write:chat": "Soạn hoặc xóa tin nhắn" _auth: shareAccessTitle: "Cho phép truy cập app" shareAccess: "Bạn có muốn cho phép \"{name}\" truy cập vào tài khoản này không?" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 444ba8bc52..4b78b0a362 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "要删掉「{x}」吗?" resetAreYouSure: "恢复默认设置?" areYouSure: "你确定吗?" saved: "已保存" -messaging: "聊天" upload: "本地上传" keepOriginalUploading: "保留原图" keepOriginalUploadingDescription: "上传图片时保留原始图片。关闭时,浏览器会在上传时生成一张用于web发布的图片。" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "上传可能需要一些时间完成。" explore: "发现" messageRead: "已读" noMoreHistory: "没有更多的历史记录" -startMessaging: "添加聊天" +startChat: "开始聊天" nUsersRead: "{n} 人已读" agreeTo: "勾选则表示已阅读并同意 {0}" agree: "同意" @@ -425,6 +424,7 @@ antennaExcludeBots: "排除机器人账户" antennaKeywordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" notifyAntenna: "开启通知" withFileAntenna: "仅带有附件的帖子" +excludeNotesInSensitiveChannel: "排除敏感频道内的帖子" enableServiceworker: "启用 ServiceWorker" antennaUsersDescription: "指定用户名,一行一个" caseSensitive: "区分大小写" @@ -491,8 +491,6 @@ noteOf: "{user} 的帖子" quoteAttached: "已引用" quoteQuestion: "是否引用此链接内容?" attachAsFileQuestion: "剪贴板内的文字过长。要转换为文本文件并添加吗?" -noMessagesYet: "现在没有新的聊天" -newMessageExists: "新信息" onlyOneFileCanBeAttached: "只能添加一个附件" signinRequired: "请先登录" signinOrContinueOnRemote: "若要继续,需要转到您所使用的实例,或者在此服务器上注册或登录。" @@ -981,6 +979,7 @@ document: "文档" numberOfPageCache: "缓存页数" numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" logoutConfirm: "是否确认登出?" +logoutWillClearClientData: "登出时将会从浏览器中删除客户端的设置信息。如果想要在再次登入时恢复设置信息,请在设置里打开自动备份。" lastActiveDate: "最后活跃时间" statusbar: "状态栏" pleaseSelect: "请选择" @@ -1334,6 +1333,64 @@ paste: "粘贴" emojiPalette: "表情符号调色板" postForm: "投稿窗口" textCount: "字数" +information: "关于" +chat: "聊天" +migrateOldSettings: "迁移旧设置信息" +migrateOldSettings_description: "通常设置信息将自动迁移。但如果由于某种原因迁移不成功,则可以手动触发迁移过程。当前的配置信息将被覆盖。" +compress: "压缩" +right: "右" +bottom: "下" +top: "上" +embed: "嵌入" +settingsMigrating: "正在迁移设置,请稍候。(之后也可以在设置 → 其它 → 迁移旧设置来手动迁移)" +readonly: "只读" +goToDeck: "返回至 Deck" +_chat: + noMessagesYet: "还没有消息" + newMessage: "新消息" + individualChat: "私聊" + individualChat_description: "可以与特定用户进行一对一聊天。" + roomChat: "群聊" + roomChat_description: "可以进行多人聊天。\n就算用户未允许私聊,只要接受了邀请,仍可以聊天。" + createRoom: "创建房间" + inviteUserToChat: "邀请用户来开始聊天" + yourRooms: "已创建的房间" + joiningRooms: "已加入的房间" + invitations: "邀请" + noInvitations: "没有邀请" + history: "历史" + noHistory: "没有历史记录" + noRooms: "没有房间" + inviteUser: "邀请用户" + sentInvitations: "已发送的邀请" + join: "加入" + ignore: "忽略" + leave: "退出房间" + members: "成员" + searchMessages: "搜索消息" + home: "首页" + send: "发送" + newline: "换行" + muteThisRoom: "静音此房间" + deleteRoom: "删除房间" + chatNotAvailableForThisAccountOrServer: "此服务器或者账户还未开启聊天功能。" + chatIsReadOnlyForThisAccountOrServer: "此服务器或者账户内的聊天为只读。无法发布新信息或创建及加入群聊。" + chatNotAvailableInOtherAccount: "对方账户目前处于无法使用聊天的状态。" + cannotChatWithTheUser: "无法与此用户聊天" + cannotChatWithTheUser_description: "可能现在无法使用聊天,或者对方未开启聊天。" + chatWithThisUser: "聊天" + thisUserAllowsChatOnlyFromFollowers: "此用户仅接受关注者发起的聊天。" + thisUserAllowsChatOnlyFromFollowing: "此用户仅接受关注的人发起的聊天。" + thisUserAllowsChatOnlyFromMutualFollowing: "此用户仅接受互相关注的人发起的聊天。" + thisUserNotAllowedChatAnyone: "此用户不接受任何人发起的聊天。" + chatAllowedUsers: "谁可以发起聊天" + chatAllowedUsers_note: "主动发起聊天时,对方将不受此设置限制。" + _chatAllowedUsers: + everyone: "任何人" + followers: "仅关注者" + following: "仅关注的人" + mutual: "仅相互关注" + none: "没有人" _emojiPalette: palettes: "调色板" enableSyncBetweenDevicesForPalettes: "启用调色板的设备间同步" @@ -1359,6 +1416,13 @@ _settings: timelineAndNote: "时间线和帖子" makeEveryTextElementsSelectable: "使所有的文字均可选择" makeEveryTextElementsSelectable_description: "若开启,在某些情况下可能降低用户体验。" + useStickyIcons: "使图标跟随滚动" + showNavbarSubButtons: "在导航栏中显示副按钮" + ifOn: "启用时" + ifOff: "关闭时" + _chat: + showSenderName: "显示发送者的名字" + sendOnEnter: "回车键发送" _preferencesProfile: profileName: "配置名" profileNameDescription: "请指定用于识别此设备的名称" @@ -1828,6 +1892,8 @@ _role: descriptionOfIsExplorable: "打开后将公开角色时间线。如果角色不是公开的,就无法公开时间线。" displayOrder: "显示顺序" descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" + preserveAssignmentOnMoveAccount: "将分配状态继承到目标账户" + preserveAssignmentOnMoveAccount_description: "启用后,当迁移具有该角色的账户时,目标账户也会继承该角色。" canEditMembersByModerator: "允许监察员编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察员和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1868,6 +1934,7 @@ _role: canImportFollowing: "允许导入关注列表" canImportMuting: "允许导入隐藏列表" canImportUserLists: "允许导入用户列表" + chatAvailability: "允许聊天" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -2064,7 +2131,6 @@ _theme: header: "顶栏" navBg: "侧边栏背景" navFg: "侧栏文本" - navHoverFg: "侧栏文本(悬停)" navActive: "侧栏文本(活动)" navIndicator: "侧栏标记" link: "链接" @@ -2087,17 +2153,15 @@ _theme: buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" driveFolderBg: "网盘的文件夹背景" - wallpaperOverlay: "壁纸叠加层" badge: "徽章" messageBg: "聊天背景" - accentDarken: "强调色(深)" - accentLighten: "强调色(浅)" fgHighlighted: "高亮显示文本" _sfx: note: "帖子" noteMy: "我的帖子" notification: "通知" reaction: "选择回应时" + chatMessage: "聊天信息" _soundSettings: driveFile: "使用网盘内的音频" driveFileWarn: "选择网盘上的文件" @@ -2244,6 +2308,8 @@ _permissions: "read:clip-favorite": "查看便签的点赞" "read:federation": "查看联合相关信息" "write:report-abuse": "举报用户" + "write:chat": "撰写或删除消息" + "read:chat": "查看聊天" _auth: shareAccessTitle: "应用程序授权许可" shareAccess: "您要授权允许 “{name}” 访问您的帐户吗?" @@ -2302,6 +2368,7 @@ _widgets: chooseList: "选择列表" clicker: "点击器" birthdayFollowings: "今天是他们的生日" + chat: "聊天" _cw: hide: "隐藏" show: "查看更多" @@ -2492,6 +2559,7 @@ _notification: newNote: "新的帖子" unreadAntennaNote: "天线 {name}" roleAssigned: "授予的角色" + chatRoomInvitationReceived: "受邀加入聊天室" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "获得成就" testNotification: "测试通知" @@ -2520,6 +2588,7 @@ _notification: receiveFollowRequest: "收到关注请求" followRequestAccepted: "关注请求已通过" roleAssigned: "授予的角色" + chatRoomInvitationReceived: "受邀加入聊天室" achievementEarned: "取得的成就" exportCompleted: "已完成导出" login: "登录" @@ -2533,6 +2602,9 @@ _notification: _deck: alwaysShowMainColumn: "总是显示主列" columnAlign: "列对齐" + columnGap: "列间距" + deckMenuPosition: "Deck 菜单位置" + navbarPosition: "导航栏位置" addColumn: "添加列" newNoteNotificationSettings: "新帖子通知设定" configureColumn: "列设置" @@ -2546,7 +2618,7 @@ _deck: newProfile: "新建配置文件" deleteProfile: "删除配置文件" introduction: "将各列进行组合以创建您自己的界面!" - introduction2: "您可以随时通过屏幕右侧的 + 来添加列" + introduction2: "可以随时通过屏幕右侧的 + 来添加列" widgetsIntroduction: "从列菜单中,选择“小工具编辑”来添加小工具" useSimpleUiForNonRootPages: "用简易UI表示非根页面" usedAsMinWidthWhenFlexible: "「自适应宽度」被启用的时候,这就是最小的宽度" @@ -2563,6 +2635,7 @@ _deck: mentions: "提及" direct: "指定用户" roleTimeline: "角色时间线" + chat: "聊天" _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" @@ -2659,6 +2732,7 @@ _moderationLogTypes: deletePage: "删除了页面" deleteFlash: "删除了 Play" deleteGalleryPost: "删除了图库稿件" + deleteChatRoom: "删除聊天室" updateProxyAccountDescription: "更新代理账户的简介" _fileViewer: title: "文件信息" @@ -2673,10 +2747,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "请在安装前确保来源可靠" _plugin: title: "要安装此插件吗?" - metaTitle: "插件信息" _theme: title: "要安装此主题吗?" - metaTitle: "主题信息" _meta: base: "基本配色方案" _vendorInfo: diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 2ea4ae8168..cfe3b729f0 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -289,7 +289,6 @@ deleteAreYouSure: "確定要刪掉「{x}」嗎?" resetAreYouSure: "確定要重設嗎?" areYouSure: "是否確定?" saved: "已儲存" -messaging: "聊天" upload: "上傳" keepOriginalUploading: "保留原圖" keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時生成適用於網路傳送的版本。" @@ -302,7 +301,7 @@ uploadFromUrlMayTakeTime: "還需要一些時間才能完成上傳。" explore: "探索" messageRead: "已讀" noMoreHistory: "沒有更多歷史紀錄" -startMessaging: "開始聊天" +startChat: "開始聊天" nUsersRead: "{n} 人已讀" agreeTo: "我同意{0}" agree: "同意" @@ -425,6 +424,7 @@ antennaExcludeBots: "排除機器人帳戶" antennaKeywordsDescription: "空格代表「以及」(AND),換行代表「或者」(OR)" notifyAntenna: "通知有新貼文" withFileAntenna: "僅帶有附件的貼文" +excludeNotesInSensitiveChannel: "排除敏感頻道的貼文" enableServiceworker: "啟用瀏覽器的推播通知" antennaUsersDescription: "填寫使用者名稱,以換行分隔" caseSensitive: "區分大小寫" @@ -491,8 +491,6 @@ noteOf: "{user}的貼文" quoteAttached: "引用" quoteQuestion: "是否要引用?" attachAsFileQuestion: "剪貼簿的文字較長。請問是否要將其以文字檔的方式附加呢?" -noMessagesYet: "沒有訊息" -newMessageExists: "有新的訊息" onlyOneFileCanBeAttached: "只能加入一個附件" signinRequired: "請先登入" signinOrContinueOnRemote: "若要繼續,需前往您所在的伺服器,或者註冊並登入此伺服器" @@ -981,6 +979,7 @@ document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" logoutConfirm: "確定要登出嗎?" +logoutWillClearClientData: "當您登出時,客戶端的設定資訊將從瀏覽器中清除。為了能夠在重新登入時恢復您的設定資訊,請啟用設定內的自動備份選項。" lastActiveDate: "上次使用日期及時間" statusbar: "狀態列" pleaseSelect: "請選擇" @@ -1310,7 +1309,7 @@ availableRoles: "可用角色" acknowledgeNotesAndEnable: "了解注意事項後再開啟。" federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" -confirmOnReact: "反應時確認" +confirmOnReact: "在做出反應前先確認" reactAreYouSure: "用「 {emoji} 」反應嗎?" markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" unmarkAsSensitiveConfirm: "要解除這個媒體的敏感設定嗎?" @@ -1334,6 +1333,63 @@ paste: "貼上" emojiPalette: "表情符號調色盤" postForm: "發文視窗" textCount: "字數" +information: "關於" +chat: "聊天" +migrateOldSettings: "遷移舊設定資訊" +migrateOldSettings_description: "通常情況下,這會自動進行,但若因某些原因未能順利遷移,您可以手動觸發遷移處理。請注意,當前的設定資訊將會被覆寫。" +compress: "壓縮" +right: "右" +bottom: "下" +top: "上" +embed: "嵌入" +settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)" +readonly: "唯讀" +_chat: + noMessagesYet: "尚無訊息" + newMessage: "新訊息" + individualChat: "ㄧ對一聊天室" + individualChat_description: "可以與特定使用者進行一對一的聊天。" + roomChat: "多人聊天室" + roomChat_description: "可以進行多人聊天。\n此外,即使是未允許個人聊天的使用者,只要對方接受,也可以進行聊天。" + createRoom: "建立聊天室" + inviteUserToChat: "邀請使用者開始聊天" + yourRooms: "已建立的聊天室" + joiningRooms: "已加入的聊天室" + invitations: "邀請" + noInvitations: "沒有邀請" + history: "歷史紀錄" + noHistory: "沒有歷史紀錄" + noRooms: "沒有可用的聊天室" + inviteUser: "邀請使用者" + sentInvitations: "已傳送的邀請" + join: "加入" + ignore: "忽視" + leave: "退出聊天室" + members: "成員" + searchMessages: "搜尋聊天訊息" + home: "首頁" + send: "發送" + newline: "換行" + muteThisRoom: "此聊天室已靜音" + deleteRoom: "刪除聊天室" + chatNotAvailableForThisAccountOrServer: "這個伺服器或這個帳號的聊天功能尚未啟用。" + chatIsReadOnlyForThisAccountOrServer: "在此伺服器或此帳戶上的聊天是唯讀的。您無法發布新訊息、建立或加入聊天室。" + chatNotAvailableInOtherAccount: "對方的帳號無法使用聊天功能。" + cannotChatWithTheUser: "無法與此使用者聊天" + cannotChatWithTheUser_description: "聊天功能目前無法使用,或對方尚未開放聊天功能。" + chatWithThisUser: "聊天" + thisUserAllowsChatOnlyFromFollowers: "此使用者僅接受來自追隨者的聊天訊息。" + thisUserAllowsChatOnlyFromFollowing: "此使用者僅接受自己追隨的使用者傳送聊天訊息。" + thisUserAllowsChatOnlyFromMutualFollowing: "此使用者只接受互相追隨的使用者傳送聊天訊息。" + thisUserNotAllowedChatAnyone: "此使用者不接受來自任何人的聊天訊息。" + chatAllowedUsers: "允許聊天的對象" + chatAllowedUsers_note: "無論此設定為何,您仍可與自己曾發送過聊天訊息的對象進行聊天。" + _chatAllowedUsers: + everyone: "任何人" + followers: "追隨自己的使用者" + following: "只有您追隨的使用者" + mutual: "互相追隨" + none: "無" _emojiPalette: palettes: "調色盤" enableSyncBetweenDevicesForPalettes: "啟用裝置與裝置之間的調色盤同步化" @@ -1359,6 +1415,14 @@ _settings: timelineAndNote: "時間軸及貼文" makeEveryTextElementsSelectable: "允許選取所有文字" makeEveryTextElementsSelectable_description: "啟用此功能後,可能會在某些情境下降低可用性。" + useStickyIcons: "使大頭貼跟隨捲動" + showNavbarSubButtons: "在導覽列顯示輔助按鈕" + ifOn: "開啟時" + ifOff: "關閉時" + enableSyncThemesBetweenDevices: "在裝置之間同步已安裝的主題" + _chat: + showSenderName: "顯示發送者的名稱" + sendOnEnter: "按下 Enter 發送訊息" _preferencesProfile: profileName: "設定檔案名稱" profileNameDescription: "設定一個名稱來識別此裝置。" @@ -1375,14 +1439,14 @@ _preferencesBackup: _accountSettings: requireSigninToViewContents: "須登入以顯示內容" requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。" - requireSigninToViewContentsDescription2: "來自不支援 URL 預覽 (OGP)、 網頁嵌入和引用貼文的伺服器,也將停止顯示。" + requireSigninToViewContentsDescription2: "針對您貼文的 URL 預覽 (OGP) 與網頁嵌入功能將會無法使用。而不支援引用貼文的伺服器,也將停止顯示。" requireSigninToViewContentsDescription3: "這些限制可能不適用於被聯邦發送至遠端伺服器的內容。" makeNotesFollowersOnlyBefore: "讓過去的貼文僅對追隨者顯示" makeNotesFollowersOnlyBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對追隨者顯示。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" makeNotesHiddenBefore: "隱藏過去的貼文" makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。" - mayNotEffectSomeSituations: "這些限制已經簡化。它們可能不適用於某些情況,例如在遠端伺服器上檢視或管理時。" + mayNotEffectSomeSituations: "這些限制僅是簡化版本。在某些情況下,例如在遠端伺服器上瀏覽或進行審核時,可能不會套用這些限制。" notesHavePassedSpecifiedPeriod: "早於指定時間的貼文" notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文" _abuseUserReport: @@ -1828,6 +1892,8 @@ _role: descriptionOfIsExplorable: "若開啟則公開角色時間軸。若角色不是公開的,則無法公開時間軸。" displayOrder: "顯示順序" descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" + preserveAssignmentOnMoveAccount: "將指派狀態承接至轉移後的帳戶" + preserveAssignmentOnMoveAccount_description: "開啟此選項後,當具備此角色的帳戶被移轉時,該角色也會承接至轉移後的帳戶。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1868,6 +1934,7 @@ _role: canImportFollowing: "允許匯入追隨名單" canImportMuting: "允許匯入靜音名單" canImportUserLists: "允許匯入清單" + chatAvailability: "允許聊天" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -2064,7 +2131,6 @@ _theme: header: "標題" navBg: "側邊欄的背景 " navFg: "側邊欄的文字" - navHoverFg: "側邊欄文字(懸浮) " navActive: "側邊欄文字(活動)" navIndicator: "側邊欄指示符" link: "連結" @@ -2087,17 +2153,15 @@ _theme: buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" driveFolderBg: "雲端硬碟文件夾背景" - wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" messageBg: "私訊背景" - accentDarken: "強調色(黑暗)" - accentLighten: "強調色(明亮)" fgHighlighted: "突顯文字" _sfx: note: "貼文" noteMy: "我的貼文" notification: "通知" reaction: "選擇反應時" + chatMessage: "聊天訊息" _soundSettings: driveFile: "使用雲端硬碟的音效檔案" driveFileWarn: "請選擇雲端硬碟中的檔案" @@ -2244,6 +2308,8 @@ _permissions: "read:clip-favorite": "查看摘錄的讚" "read:federation": "查看站台聯邦的相關資訊" "write:report-abuse": "檢舉違規行為" + "write:chat": "撰寫或刪除訊息" + "read:chat": "查看聊天訊息" _auth: shareAccessTitle: "應用程式的存取權限" shareAccess: "要授權「“{name}”」存取您的帳戶嗎?" @@ -2302,6 +2368,7 @@ _widgets: chooseList: "選擇清單" clicker: "點擊器" birthdayFollowings: "今天生日的使用者" + chat: "聊天" _cw: hide: "隱藏" show: "顯示內容" @@ -2492,6 +2559,7 @@ _notification: newNote: "新的貼文" unreadAntennaNote: "天線 {name}" roleAssigned: "已授予角色" + chatRoomInvitationReceived: "您被邀請加入聊天室" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "獲得成就" testNotification: "通知測試" @@ -2520,6 +2588,7 @@ _notification: receiveFollowRequest: "已收到追隨請求" followRequestAccepted: "追隨請求已接受" roleAssigned: "已授予角色" + chatRoomInvitationReceived: "已被邀請加入聊天室" achievementEarned: "獲得成就" exportCompleted: "已完成匯出。" login: "登入" @@ -2533,6 +2602,9 @@ _notification: _deck: alwaysShowMainColumn: "總是顯示主欄" columnAlign: "對齊欄位" + columnGap: "欄與欄之間的邊距" + deckMenuPosition: "多欄模式的選單位置" + navbarPosition: "導覽列位置" addColumn: "新增欄位" newNoteNotificationSettings: "新貼文通知的設定" configureColumn: "欄位的設定" @@ -2563,6 +2635,7 @@ _deck: mentions: "提及" direct: "指定使用者" roleTimeline: "角色時間軸" + chat: "聊天" _dialog: charactersExceeded: "您的貼文太長了!現時字數 {current}/限制字數 {max}" charactersBelow: "您的貼文太短了!現時字數 {current}/限制字數 {min}" @@ -2659,6 +2732,7 @@ _moderationLogTypes: deletePage: "刪除頁面" deleteFlash: "刪除 Play" deleteGalleryPost: "刪除相簿的貼文" + deleteChatRoom: "刪除聊天室" updateProxyAccountDescription: "更新代理帳戶的說明" _fileViewer: title: "檔案詳細資訊" @@ -2673,10 +2747,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "安裝前請確認提供者是可信賴的。" _plugin: title: "要安裝此外掛嘛?" - metaTitle: "外掛資訊" _theme: title: "要安裝此佈景主題嗎?" - metaTitle: "佈景主題資訊" _meta: base: "基本配色方案" _vendorInfo: diff --git a/package.json b/package.json index f43c8fc391..175ee9d2d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2025.3.2-beta.4", + "version": "2025.4.1-alpha.2", "codename": "nasubi", "repository": { "type": "git", @@ -24,7 +24,6 @@ "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", - "build-frontend-search-index": "pnpm --filter frontend build-search-index", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", "init": "pnpm migrate", diff --git a/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js b/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js new file mode 100644 index 0000000000..74225de96a --- /dev/null +++ b/packages/backend/migration/1736230492103-addAntennaHideNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAntennaHideNotesInSensitiveChannel1736230492103 { + name = 'AddAntennaHideNotesInSensitiveChannel1736230492103' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "hideNotesInSensitiveChannel" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/migration/1742203321812-chat.js b/packages/backend/migration/1742203321812-chat.js new file mode 100644 index 0000000000..3d8f7276b5 --- /dev/null +++ b/packages/backend/migration/1742203321812-chat.js @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat1742203321812 { + name = 'Chat1742203321812' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room" ("id" character varying(32) NOT NULL, "name" character varying(256) NOT NULL, "ownerId" character varying(32) NOT NULL, CONSTRAINT "PK_8aa3a52cf74c96469f0ef9fbe3e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f0d8ad64243fa2ca2800da0dfd" ON "chat_room" ("ownerId") `); + await queryRunner.query(`CREATE TABLE "chat_message" ("id" character varying(32) NOT NULL, "fromUserId" character varying(32) NOT NULL, "toUserId" character varying(32), "toRoomId" character varying(32), "text" character varying(4096), "uri" character varying(512), "reads" character varying(32) array NOT NULL DEFAULT '{}', "fileId" character varying(32), "reactions" character varying(1024) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_3cc0d85193aade457d3077dd06b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_79a26e7a4d9afa5e4fc05f134e" ON "chat_message" ("fromUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_25e097b51d7622c249452c6f75" ON "chat_message" ("toUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_f006b8a76efd1abf9f221c175c" ON "chat_message" ("toRoomId") `); + await queryRunner.query(`CREATE TABLE "chat_room_membership" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_2bd59c741e571b283c048beb69a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_d99c5279460fb77ef58c596ce5" ON "chat_room_membership" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_c25143ebab714e930aeca1c0e8" ON "chat_room_membership" ("roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room" ADD CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed" FOREIGN KEY ("fromUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_25e097b51d7622c249452c6f757" FOREIGN KEY ("toUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce" FOREIGN KEY ("toRoomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_d99c5279460fb77ef58c596ce51" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d"`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_d99c5279460fb77ef58c596ce51"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_25e097b51d7622c249452c6f757"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c25143ebab714e930aeca1c0e8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d99c5279460fb77ef58c596ce5"`); + await queryRunner.query(`DROP TABLE "chat_room_membership"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f006b8a76efd1abf9f221c175c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25e097b51d7622c249452c6f75"`); + await queryRunner.query(`DROP INDEX "public"."IDX_79a26e7a4d9afa5e4fc05f134e"`); + await queryRunner.query(`DROP TABLE "chat_message"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f0d8ad64243fa2ca2800da0dfd"`); + await queryRunner.query(`DROP TABLE "chat_room"`); + } +} diff --git a/packages/backend/migration/1742608337548-chat-2.js b/packages/backend/migration/1742608337548-chat-2.js new file mode 100644 index 0000000000..9f74a263d6 --- /dev/null +++ b/packages/backend/migration/1742608337548-chat-2.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat21742608337548 { + name = 'Chat21742608337548' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "chatScope" character varying(128) NOT NULL DEFAULT 'mutual'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_185b6b5afa707b5d36d1ce3144" ON "chat_room_membership" ("userId", "roomId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_185b6b5afa707b5d36d1ce3144"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "chatScope"`); + } +} diff --git a/packages/backend/migration/1742617546147-chat-3.js b/packages/backend/migration/1742617546147-chat-3.js new file mode 100644 index 0000000000..116b9a738b --- /dev/null +++ b/packages/backend/migration/1742617546147-chat-3.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat31742617546147 { + name = 'Chat31742617546147' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_approval" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "otherId" character varying(32) NOT NULL, CONSTRAINT "PK_fbbb95d60acf5c85388345b5f5d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_530257863e1381a7f2f1d3282f" ON "chat_approval" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_b1d46037f23d170da5c05fdf75" ON "chat_approval" ("otherId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_12c4768a2f706fc267f2078903" ON "chat_approval" ("userId", "otherId") `); + await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_530257863e1381a7f2f1d3282fe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_b1d46037f23d170da5c05fdf755" FOREIGN KEY ("otherId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_b1d46037f23d170da5c05fdf755"`); + await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_530257863e1381a7f2f1d3282fe"`); + await queryRunner.query(`DROP INDEX "public"."IDX_12c4768a2f706fc267f2078903"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1d46037f23d170da5c05fdf75"`); + await queryRunner.query(`DROP INDEX "public"."IDX_530257863e1381a7f2f1d3282f"`); + await queryRunner.query(`DROP TABLE "chat_approval"`); + } +} diff --git a/packages/backend/migration/1742707840715-chat-4.js b/packages/backend/migration/1742707840715-chat-4.js new file mode 100644 index 0000000000..953a53d880 --- /dev/null +++ b/packages/backend/migration/1742707840715-chat-4.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat41742707840715 { + name = 'Chat41742707840715' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`); + await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`); + await queryRunner.query(`DROP TABLE "chat_room_invitation"`); + } +} diff --git a/packages/backend/migration/1742721896936-chat-5.js b/packages/backend/migration/1742721896936-chat-5.js new file mode 100644 index 0000000000..00db787cb7 --- /dev/null +++ b/packages/backend/migration/1742721896936-chat-5.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat51742721896936 { + name = 'Chat51742721896936' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD "ignored" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP COLUMN "ignored"`); + } +} diff --git a/packages/backend/migration/1742795111958-chat-6.js b/packages/backend/migration/1742795111958-chat-6.js new file mode 100644 index 0000000000..9a5dc3e32f --- /dev/null +++ b/packages/backend/migration/1742795111958-chat-6.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat61742795111958 { + name = 'Chat61742795111958' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room" ADD "description" character varying(2048) NOT NULL DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "chat_room" ADD "isArchived" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD "isMuted" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP COLUMN "isMuted"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "isArchived"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "description"`); + } +} diff --git a/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js b/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js new file mode 100644 index 0000000000..ff4f7a051b --- /dev/null +++ b/packages/backend/migration/1743558299182-RoleCopyOnMoveAccount.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RoleCopyOnMoveAccount1743558299182 { + name = 'RoleCopyOnMoveAccount1743558299182' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "preserveAssignmentOnMoveAccount" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "preserveAssignmentOnMoveAccount"`); + } +} diff --git a/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js new file mode 100644 index 0000000000..1e8faafbc4 --- /dev/null +++ b/packages/backend/migration/1744075766000-excludeNotesInSensitiveChannel.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ExcludeNotesInSensitiveChannel1744075766000 { + name = 'ExcludeNotesInSensitiveChannel1744075766000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "hideNotesInSensitiveChannel" TO "excludeNotesInSensitiveChannel"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" RENAME COLUMN "excludeNotesInSensitiveChannel" TO "hideNotesInSensitiveChannel"`); + } +} diff --git a/packages/backend/migration/1745378064470-composite-note-index.js b/packages/backend/migration/1745378064470-composite-note-index.js new file mode 100644 index 0000000000..49e835d38c --- /dev/null +++ b/packages/backend/migration/1745378064470-composite-note-index.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CompositeNoteIndex1745378064470 { + name = 'CompositeNoteIndex1745378064470'; + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`); + // Flush all cached Linear Scan Plans and redo statistics for composite index + // this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly + await queryRunner.query(`ANALYZE "user", "note"`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); + await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 502323bf61..5ae856f67a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,17 +37,17 @@ }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.10.16", - "@swc/core-darwin-x64": "1.10.16", + "@swc/core-darwin-arm64": "1.11.18", + "@swc/core-darwin-x64": "1.11.18", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.10.16", - "@swc/core-linux-arm64-gnu": "1.10.16", - "@swc/core-linux-arm64-musl": "1.10.16", - "@swc/core-linux-x64-gnu": "1.10.16", - "@swc/core-linux-x64-musl": "1.10.16", - "@swc/core-win32-arm64-msvc": "1.10.16", - "@swc/core-win32-ia32-msvc": "1.10.16", - "@swc/core-win32-x64-msvc": "1.10.16", + "@swc/core-linux-arm-gnueabihf": "1.11.18", + "@swc/core-linux-arm64-gnu": "1.11.18", + "@swc/core-linux-arm64-musl": "1.11.18", + "@swc/core-linux-x64-gnu": "1.11.18", + "@swc/core-linux-x64-musl": "1.11.18", + "@swc/core-win32-arm64-msvc": "1.11.18", + "@swc/core-win32-ia32-msvc": "1.11.18", + "@swc/core-win32-x64-msvc": "1.11.18", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", "bufferutil": "4.0.9", @@ -67,23 +67,23 @@ "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.749.0", - "@aws-sdk/lib-storage": "3.749.0", + "@aws-sdk/client-s3": "3.782.0", + "@aws-sdk/lib-storage": "3.782.0", "@discordapp/twemoji": "15.1.0", "@fastify/accepts": "5.0.2", "@fastify/cookie": "11.0.2", - "@fastify/cors": "10.0.2", + "@fastify/cors": "10.1.0", "@fastify/express": "4.0.2", "@fastify/http-proxy": "10.0.2", "@fastify/multipart": "9.0.3", - "@fastify/static": "8.1.0", + "@fastify/static": "8.1.1", "@fastify/view": "10.0.2", - "@misskey-dev/sharp-read-bmp": "1.2.0", + "@misskey-dev/sharp-read-bmp": "1.3.0", "@misskey-dev/summaly": "5.2.0", - "@napi-rs/canvas": "0.1.67", - "@nestjs/common": "11.0.9", - "@nestjs/core": "11.0.9", - "@nestjs/testing": "11.0.9", + "@napi-rs/canvas": "0.1.69", + "@nestjs/common": "11.0.16", + "@nestjs/core": "11.0.15", + "@nestjs/testing": "11.0.15", "@peertube/http-signature": "1.7.0", "@sentry/node": "8.55.0", "@sentry/profiling-node": "8.55.0", @@ -91,8 +91,9 @@ "@sinonjs/fake-timers": "11.3.1", "@smithy/node-http-handler": "2.5.0", "@swc/cli": "0.6.0", - "@swc/core": "1.10.16", + "@swc/core": "1.11.18", "@twemoji/parser": "15.1.1", + "@types/redis-info": "3.0.3", "accepts": "1.3.8", "ajv": "8.17.1", "archiver": "7.0.1", @@ -100,7 +101,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.41.1", + "bullmq": "5.48.1", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.4.1", @@ -111,18 +112,18 @@ "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fastify": "5.2.1", + "fastify": "5.3.2", "fastify-raw-body": "5.0.0", "feed": "4.2.2", "file-type": "19.6.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.2", - "got": "14.4.6", + "got": "14.4.7", "happy-dom": "16.8.1", "hpagent": "1.2.0", "htmlescape": "1.1.1", "http-link-header": "1.1.3", - "ioredis": "5.5.0", + "ioredis": "5.6.0", "ip-cidr": "4.0.2", "ipaddr.js": "2.2.0", "is-svg": "5.1.0", @@ -131,26 +132,26 @@ "json5": "2.2.3", "jsonld": "8.3.3", "jsrsasign": "11.1.0", - "juice": "11.0.0", - "meilisearch": "0.48.2", + "juice": "11.0.1", + "meilisearch": "0.49.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.1", - "nanoid": "5.1.0", + "nanoid": "5.1.5", "nested-property": "4.0.0", "node-fetch": "3.3.2", "nodemailer": "6.10.0", "nsfwjs": "4.2.0", - "oauth": "0.10.0", + "oauth": "0.10.2", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.3.6", + "otpauth": "9.4.0", "parse5": "7.2.1", - "pg": "8.13.3", + "pg": "8.14.1", "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", @@ -159,33 +160,35 @@ "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.21.4", + "redis-info": "3.1.0", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", - "rxjs": "7.8.1", - "sanitize-html": "2.14.0", + "rxjs": "7.8.2", + "sanitize-html": "2.15.0", "secure-json-parse": "3.0.2", - "sharp": "0.33.5", + "sharp": "0.34.1", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "systeminformation": "5.25.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", - "tsc-alias": "1.8.10", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typeorm": "0.3.20", - "typescript": "5.7.3", - "ulid": "2.3.0", + "typeorm": "0.3.22", + "typescript": "5.8.3", + "ulid": "2.4.0", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.18.0", + "ws": "8.18.1", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.7.0", "@nestjs/platform-express": "10.4.15", + "@sentry/vue": "9.12.0", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.37", "@types/accepts": "1.3.7", @@ -204,7 +207,7 @@ "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "22.13.4", + "@types/node": "22.14.0", "@types/nodemailer": "6.4.17", "@types/oauth": "0.9.6", "@types/oauth2orize": "1.11.5", @@ -215,17 +218,17 @@ "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.13.0", - "@types/semver": "7.5.8", + "@types/sanitize-html": "2.15.0", + "@types/semver": "7.7.0", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", - "@types/ws": "8.5.14", - "@typescript-eslint/eslint-plugin": "8.24.0", - "@typescript-eslint/parser": "8.24.0", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", "aws-sdk-client-mock": "4.1.0", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 32ea700748..646fa07911 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -7,7 +7,8 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; -import * as Sentry from '@sentry/node'; +import type * as Sentry from '@sentry/node'; +import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; type RedisOptionsSource = Partial & { @@ -62,7 +63,12 @@ type Source = { scope?: 'local' | 'global' | string[]; }; sentryForBackend?: { options: Partial; enableNodeProfiling: boolean; }; - sentryForFrontend?: { options: Partial }; + sentryForFrontend?: { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; + }; publishTarballInsteadOfProvideRepositoryUrl?: boolean; @@ -198,7 +204,12 @@ export type Config = { redisForTimelines: RedisOptions & RedisOptionsSource; redisForReactions: RedisOptions & RedisOptionsSource; sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; - sentryForFrontend: { options: Partial } | undefined; + sentryForFrontend: { + options: Partial & { dsn: string }; + vueIntegration?: SentryVue.VueIntegrationOptions | null; + browserTracingIntegration?: Parameters[0] | null; + replayIntegration?: Parameters[0] | null; + } | undefined; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 0fbb9bcd80..f8e3eaf01f 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -24,6 +24,8 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { AntennaService } from '@/core/AntennaService.js'; @Injectable() export class AccountMoveService { @@ -61,6 +63,8 @@ export class AccountMoveService { private relayService: RelayService, private queueService: QueueService, private systemAccountService: SystemAccountService, + private roleService: RoleService, + private antennaService: AntennaService, ) { } @@ -119,7 +123,9 @@ export class AccountMoveService { await Promise.all([ this.copyBlocking(src, dst), this.copyMutings(src, dst), + this.copyRoles(src, dst), this.updateLists(src, dst), + this.antennaService.onMoveAccount(src, dst), ]); } catch { /* skip if any error happens */ @@ -201,6 +207,32 @@ export class AccountMoveService { await this.mutingsRepository.insert(arrayToInsert); } + @bindThis + public async copyRoles(src: ThinUser, dst: ThinUser): Promise { + // Insert new roles with the same values except userId + // role service may have cache for roles so retrieve roles from service + const [oldRoleAssignments, roles] = await Promise.all([ + this.roleService.getUserAssigns(src.id), + this.roleService.getRoles(), + ]); + + if (oldRoleAssignments.length === 0) return; + + // No promise all since the only async operation is writing to the database + for (const oldRoleAssignment of oldRoleAssignments) { + const role = roles.find(x => x.id === oldRoleAssignment.roleId); + if (role == null) continue; // Very unlikely however removing role may cause this case + if (!role.preserveAssignmentOnMoveAccount) continue; + + try { + await this.roleService.assign(dst.id, role.id, oldRoleAssignment.expiresAt); + } catch (e) { + if (e instanceof RoleService.AlreadyAssignedError) continue; + throw e; + } + } + } + /** * Update lists while moving accounts. * - No removal of the old account from the lists diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index e827ffa68c..ec79675b06 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -5,18 +5,20 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { In } from 'typeorm'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import * as Acct from '@/misc/acct.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; import type { MiAntenna } from '@/models/Antenna.js'; import type { MiNote } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { CacheService } from './CacheService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, + private cacheService: CacheService, private utilityService: UtilityService, private globalEventService: GlobalEventService, private fanoutTimelineService: FanoutTimelineService, @@ -111,8 +114,7 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise { - if (note.visibility === 'specified') return false; - if (note.visibility === 'followers') return false; + if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false; if (antenna.excludeBots && noteUser.isBot) return false; @@ -120,6 +122,18 @@ export class AntennaService implements OnApplicationShutdown { if (!antenna.withReplies && note.replyId != null) return false; + if (note.visibility === 'specified') { + if (note.userId !== antenna.userId) { + if (note.visibleUserIds == null) return false; + if (!note.visibleUserIds.includes(antenna.userId)) return false; + } + } + + if (note.visibility === 'followers') { + const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId); + if (!isFollowing && antenna.userId !== note.userId) return false; + } + if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { @@ -206,6 +220,41 @@ export class AntennaService implements OnApplicationShutdown { return this.antennas; } + @bindThis + public async onMoveAccount(src: MiUser, dst: MiUser): Promise { + // There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it. + + // Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list + const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase(); + const antennasToMigrate = (await this.getAntennas()).filter(antenna => { + return antenna.users.some(user => { + const { username, host } = Acct.parse(user); + return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct; + }); + }); + + if (antennasToMigrate.length === 0) return; + + const antennaIds = antennasToMigrate.map(x => x.id); + + // Update the antennas by appending dst users acct to the users list + const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host }); + + await this.antennasRepository.createQueryBuilder('antenna') + .update() + .set({ + users: () => 'array_append(antenna.users, :dstUserAcct)', + }) + .where('antenna.id IN (:...antennaIds)', { antennaIds }) + .setParameters({ dstUserAcct }) + .execute(); + + // announce update to event + for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) { + this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna); + } + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onRedisMessage); diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts new file mode 100644 index 0000000000..9d294a80cb --- /dev/null +++ b/packages/backend/src/core/ChatService.ts @@ -0,0 +1,925 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { bindThis } from '@/decorators.js'; +import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { Packed } from '@/misc/json-schema.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { emojiRegex } from '@/misc/emoji-regex.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +const MAX_ROOM_MEMBERS = 30; +const MAX_REACTIONS_PER_MESSAGE = 100; +const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; + +// TODO: ReactionServiceのやつと共通化 +function normalizeEmojiString(x: string) { + const match = emojiRegex.exec(x); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } else { + throw new Error('invalid emoji'); + } +} + +@Injectable() +export class ChatService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatApprovalsRepository) + private chatApprovalsRepository: ChatApprovalsRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private chatEntityService: ChatEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, + private userBlockingService: UserBlockingService, + private queryService: QueryService, + private roleService: RoleService, + private userFollowingService: UserFollowingService, + private customEmojiService: CustomEmojiService, + private moderationLogService: ModerationLogService, + ) { + } + + @bindThis + public async getChatAvailability(userId: MiUser['id']): Promise<{ read: boolean; write: boolean; }> { + const policies = await this.roleService.getUserPolicies(userId); + + switch (policies.chatAvailability) { + case 'available': + return { + read: true, + write: true, + }; + case 'readonly': + return { + read: true, + write: false, + }; + case 'unavailable': + return { + read: false, + write: false, + }; + default: + throw new Error('invalid chat availability (unreachable)'); + } + } + + /** getChatAvailabilityの糖衣。主にAPI呼び出し時に走らせて、権限的に問題ない場合はそのまま続行する */ + @bindThis + public async checkChatAvailability(userId: MiUser['id'], permission: 'read' | 'write') { + const policy = await this.getChatAvailability(userId); + if (policy[permission] === false) { + throw new Error('ROLE_PERMISSION_DENIED'); + } + } + + @bindThis + public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise> { + if (fromUser.id === toUser.id) { + throw new Error('yourself'); + } + + const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval') + .where(new Brackets(qb => { // 自分が相手を許可しているか + qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id }) + .andWhere('approval.otherId = :toUserId', { toUserId: toUser.id }); + })) + .orWhere(new Brackets(qb => { // 相手が自分を許可しているか + qb.where('approval.userId = :toUserId', { toUserId: toUser.id }) + .andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id }); + })) + .take(2) + .getMany(); + + const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id); + const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id); + + if (!otherApprovedMe) { + if (toUser.chatScope === 'none') { + throw new Error('recipient is cannot chat (none)'); + } else if (toUser.chatScope === 'followers') { + const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id); + if (!isFollower) { + throw new Error('recipient is cannot chat (followers)'); + } + } else if (toUser.chatScope === 'following') { + const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id); + if (!isFollowing) { + throw new Error('recipient is cannot chat (following)'); + } + } else if (toUser.chatScope === 'mutual') { + const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id); + if (!isMutual) { + throw new Error('recipient is cannot chat (mutual)'); + } + } + } + + if (!(await this.getChatAvailability(toUser.id)).write) { + throw new Error('recipient is cannot chat (policy)'); + } + + const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); + if (blocked) { + throw new Error('blocked'); + } + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toUserId: toUser.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + // 相手を許可しておく + if (!iApprovedOther) { + this.chatApprovalsRepository.insertOne({ + id: this.idService.gen(), + userId: fromUser.id, + otherId: toUser.id, + }); + } + + const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted); + + if (this.userEntityService.isLocalUser(toUser)) { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`); + redisPipeline.exec(); + } + + if (this.userEntityService.isLocalUser(fromUser)) { + // 自分のストリーム + this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage); + } + + if (this.userEntityService.isLocalUser(toUser)) { + // 相手のストリーム + this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage); + } + + // 3秒経っても既読にならなかったらイベント発行 + if (this.userEntityService.isLocalUser(toUser)) { + setTimeout(async () => { + const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`); + + if (marker == null) return; // 既読 + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); + this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); + }, 3000); + } + + return packedMessage; + } + + @bindThis + public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise> { + const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id })).map(m => ({ + userId: m.userId, + isMuted: m.isMuted, + })).concat({ // ownerはmembershipレコードを作らないため + userId: toRoom.ownerId, + isMuted: false, + }); + + if (!memberships.some(member => member.userId === fromUser.id)) { + throw new Error('you are not a member of the room'); + } + + const membershipsOtherThanMe = memberships.filter(member => member.userId !== fromUser.id); + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toRoomId: toRoom.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted); + + this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage); + + const redisPipeline = this.redisClient.pipeline(); + for (const membership of membershipsOtherThanMe) { + if (membership.isMuted) continue; + + redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`); + } + redisPipeline.exec(); + + // 3秒経っても既読にならなかったらイベント発行 + setTimeout(async () => { + const redisPipeline = this.redisClient.pipeline(); + for (const membership of membershipsOtherThanMe) { + redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`); + } + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + if (markers.every(marker => marker[1] == null)) return; + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted); + + for (let i = 0; i < membershipsOtherThanMe.length; i++) { + const marker = markers[i][1]; + if (marker == null) continue; + + this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + } + }, 3000); + + return packedMessage; + } + + @bindThis + public async readUserChatMessage( + readerId: MiUser['id'], + senderId: MiUser['id'], + ): Promise { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`); + await redisPipeline.exec(); + } + + @bindThis + public async readRoomChatMessage( + readerId: MiUser['id'], + roomId: MiChatRoom['id'], + ): Promise { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`); + await redisPipeline.exec(); + } + + @bindThis + public findMessageById(messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId }); + } + + @bindThis + public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId }); + } + + @bindThis + public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) { + if (await this.isRoomMember(room, meId)) { + return true; + } else { + const iAmModerator = await this.roleService.isModerator({ id: meId }); + if (iAmModerator) { + return true; + } + + return false; + } + } + + @bindThis + public async deleteMessage(message: MiChatMessage) { + await this.chatMessagesRepository.delete(message.id); + + if (message.toUserId) { + const [fromUser, toUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: message.fromUserId }), + this.usersRepository.findOneByOrFail({ id: message.toUserId }), + ]); + + if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { + //const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser)); + //this.queueService.deliver(fromUser, activity, toUser.inbox); + } + } else if (message.toRoomId) { + this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id); + } + } + + @bindThis + public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', otherId); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere('message.toRoomId = :roomId', { roomId }) + .leftJoinAndSelect('message.file', 'file') + .leftJoinAndSelect('message.fromUser', 'fromUser'); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async userHistory(meId: MiUser['id'], limit: number): Promise { + const history: MiChatMessage[] = []; + + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + for (let i = 0; i < limit; i++) { + const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId', { meId: meId }) + .orWhere('message.toUserId = :meId', { meId: meId }); + })) + .andWhere('message.toRoomId IS NULL') + .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`); + + if (found.length > 0) { + query.andWhere('message.fromUserId NOT IN (:...found)', { found: found }); + query.andWhere('message.toUserId NOT IN (:...found)', { found: found }); + } + + query.setParameters(mutingQuery.getParameters()); + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async roomHistory(meId: MiUser['id'], limit: number): Promise { + // TODO: 一回のクエリにまとめられるかも + const [memberRoomIds, ownedRoomIds] = await Promise.all([ + this.chatRoomMembershipsRepository.findBy({ + userId: meId, + }).then(xs => xs.map(x => x.roomId)), + this.chatRoomsRepository.findBy({ + ownerId: meId, + }).then(xs => xs.map(x => x.id)), + ]); + + const roomIds = memberRoomIds.concat(ownedRoomIds); + + if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) { + return []; + } + + const history: MiChatMessage[] = []; + + for (let i = 0; i < limit; i++) { + const found = history.map(m => m.toRoomId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where('message.toRoomId IN (:...roomIds)', { roomIds }); + + if (found.length > 0) { + query.andWhere('message.toRoomId NOT IN (:...found)', { found: found }); + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const otherId of otherIds) { + redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < otherIds.length; i++) { + const marker = markers[i][1]; + readStateMap[otherIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const roomId of roomIds) { + redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < roomIds.length; i++) { + const marker = markers[i][1]; + readStateMap[roomIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async hasUnreadMessages(userId: MiUser['id']) { + const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`); + return card > 0; + } + + @bindThis + public async createRoom(owner: MiUser, params: Partial<{ + name: string; + description: string; + }>) { + const room = { + id: this.idService.gen(), + name: params.name, + description: params.description, + ownerId: owner.id, + } satisfies Partial; + + const created = await this.chatRoomsRepository.insertOne(room); + + return created; + } + + @bindThis + public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) { + if (room.ownerId === meId) { + return true; + } + + const iAmModerator = await this.roleService.isModerator({ id: meId }); + if (iAmModerator) { + return true; + } + + return false; + } + + @bindThis + public async deleteRoom(room: MiChatRoom, deleter?: MiUser) { + await this.chatRoomsRepository.delete(room.id); + + if (deleter) { + const deleterIsModerator = await this.roleService.isModerator(deleter); + + if (deleterIsModerator) { + this.moderationLogService.log(deleter, 'deleteChatRoom', { + roomId: room.id, + room: room, + }); + } + } + } + + @bindThis + public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId }); + } + + @bindThis + public async findRoomById(roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] }); + } + + @bindThis + public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) { + if (room.ownerId === userId) return true; + const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId }); + return membership != null; + } + + @bindThis + public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) { + if (inviterId === inviteeId) { + throw new Error('yourself'); + } + + const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId }); + + if (await this.isRoomMember(room, inviteeId)) { + throw new Error('already member'); + } + + const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId }); + if (existingInvitation) { + throw new Error('already invited'); + } + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + // TODO: cehck block + + const invitation = { + id: this.idService.gen(), + roomId: room.id, + userId: inviteeId, + } satisfies Partial; + + const created = await this.chatRoomInvitationsRepository.insertOne(invitation); + + this.notificationService.createNotification(inviteeId, 'chatRoomInvitationReceived', { + invitationId: invitation.id, + }, inviterId); + + return created; + } + + @bindThis + public async getSentRoomInvitationsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .andWhere('invitation.roomId = :roomId', { roomId }); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId) + .andWhere('room.ownerId = :ownerId', { ownerId }); + + const rooms = await query.take(limit).getMany(); + + return rooms; + } + + @bindThis + public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .andWhere('invitation.userId = :userId', { userId }) + .andWhere('invitation.ignored = FALSE'); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + const membership = { + id: this.idService.gen(), + roomId: roomId, + userId: userId, + } satisfies Partial; + + // TODO: transaction + await this.chatRoomMembershipsRepository.insertOne(membership); + await this.chatRoomInvitationsRepository.delete(invitation.id); + } + + @bindThis + public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true }); + } + + @bindThis + public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.delete(membership.id); + } + + @bindThis + public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute }); + } + + @bindThis + public async updateRoom(room: MiChatRoom, params: { + name?: string; + description?: string; + }): Promise { + return this.chatRoomsRepository.createQueryBuilder().update() + .set(params) + .where('id = :id', { id: room.id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + } + + @bindThis + public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .andWhere('membership.roomId = :roomId', { roomId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } + + @bindThis + public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: { + userId?: MiUser['id'] | null; + roomId?: MiChatRoom['id'] | null; + }) { + const q = this.chatMessagesRepository.createQueryBuilder('message'); + + if (params.userId) { + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', params.userId); + } else if (params.roomId) { + q.where('message.toRoomId = :roomId', { roomId: params.roomId }); + } else { + const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership') + .select('membership.roomId') + .where('membership.userId = :meId', { meId: meId }); + + const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room') + .select('room.id') + .where('room.ownerId = :meId', { meId }); + + q.andWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .orWhere('message.toUserId = :meId') + .orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`) + .orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`); + })); + + q.setParameters(membershipsQuery.getParameters()); + q.setParameters(ownedRoomsQuery.getParameters()); + } + + q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` }); + + q.leftJoinAndSelect('message.file', 'file'); + q.leftJoinAndSelect('message.fromUser', 'fromUser'); + q.leftJoinAndSelect('message.toUser', 'toUser'); + q.leftJoinAndSelect('message.toRoom', 'toRoom'); + q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner'); + + const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany(); + + return messages; + } + + @bindThis + public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalizeEmojiString(reaction_); + } else { + const name = custom[1]; + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); + + if (emoji == null) { + throw new Error('no such emoji'); + } else { + reaction = `:${name}:`; + } + } + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + if (message.fromUserId === userId) { + throw new Error('cannot react to own message'); + } + + if (message.toRoomId === null && message.toUserId !== userId) { + throw new Error('cannot react to others message'); + } + + if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) { + throw new Error('too many reactions'); + } + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + if (room) { + if (!await this.isRoomMember(room, userId)) { + throw new Error('cannot react to others message'); + } + } + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_append("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'react', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalizeEmojiString(reaction_); + } else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし + const name = custom[1]; + reaction = `:${name}:`; + } + + // NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_remove("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + // TODO: 実際に削除が行われたときのみイベントを発行する + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'unreact', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .andWhere('membership.userId = :userId', { userId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 88cccff0ba..a0136210bc 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -45,7 +45,6 @@ import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; -import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; @@ -76,6 +75,7 @@ import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; @@ -101,6 +101,7 @@ import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; import { BlockingEntityService } from './entities/BlockingEntityService.js'; import { ChannelEntityService } from './entities/ChannelEntityService.js'; +import { ChatEntityService } from './entities/ChatEntityService.js'; import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; @@ -185,7 +186,6 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; -const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NoteMutingService: Provider = { provide: 'NoteMutingService', useExisting: NoteMutingService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; @@ -223,6 +223,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 $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; @@ -249,6 +250,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService }; const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService }; +const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService }; const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService }; const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; @@ -335,7 +337,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, - NoteReadService, NoteMutingService, NotificationService, PollService, @@ -373,6 +374,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -399,6 +401,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -481,7 +484,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, - $NoteReadService, $NoteMutingService, $NotificationService, $PollService, @@ -519,6 +521,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -545,6 +548,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, @@ -628,7 +632,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, - NoteReadService, NoteMutingService, NotificationService, PollService, @@ -666,6 +669,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -691,6 +695,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -773,7 +778,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, - $NoteReadService, $NoteMutingService, $NotificationService, $PollService, @@ -810,6 +814,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -835,6 +840,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index fd2dfa2043..15fe250b43 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -56,7 +56,7 @@ export class FanoutTimelineEndpointService { } @bindThis - private async getMiNotes(ps: TimelineOptions): Promise { + async getMiNotes(ps: TimelineOptions): Promise { // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 7e4893a1a4..8d88200efc 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiAvatarDecoration, MiNoteMuting, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiNoteMuting, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -72,12 +72,8 @@ export interface MainEventTypes { readAllNotifications: undefined; notificationFlushed: undefined; unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; unreadAntenna: MiAntenna; + newChatMessage: Packed<'ChatMessage'>; readAllAnnouncements: undefined; myTokenRegenerated: undefined; signin: { @@ -163,6 +159,21 @@ export interface AdminEventTypes { }; } +export interface ChatEventTypes { + message: Packed<'ChatMessageLite'>; + deleted: Packed<'ChatMessageLite'>['id']; + react: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; + unreact: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; +} + export interface ReversiEventTypes { matched: { game: Packed<'ReversiGameDetailed'>; @@ -202,7 +213,7 @@ export interface ReversiGameEventTypes { type Events = { [K in keyof T]: { type: K; body: T[K]; } }; type EventUnionFromDictionary< T extends object, - U = Events + U = Events, > = U[keyof U]; type SerializedAll = { @@ -297,6 +308,14 @@ export type GlobalEvents = { name: 'notesStream'; payload: Serialized>; }; + chatUser: { + name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`; + payload: EventTypesToEventPayload; + }; + chatRoom: { + name: `chatRoomStream:${MiChatRoom['id']}`; + payload: EventTypesToEventPayload; + }; reversi: { name: `reversiStream:${MiUser['id']}`; payload: EventTypesToEventPayload; @@ -395,6 +414,16 @@ export class GlobalEventService { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishChatUserStream(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis + public publishChatRoomStream(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishReversiStream(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void { this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 10df6ef266..223a8de678 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -7,13 +7,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js'; -import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js'; -import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js'; -import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js'; -import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js'; +import { genAid, isSafeAidT, parseAid, parseAidFull } from '@/misc/id/aid.js'; +import { genAidx, isSafeAidxT, parseAidx, parseAidxFull } from '@/misc/id/aidx.js'; +import { genMeid, isSafeMeidT, parseMeid, parseMeidFull } from '@/misc/id/meid.js'; +import { genMeidg, isSafeMeidgT, parseMeidg, parseMeidgFull } from '@/misc/id/meidg.js'; +import { genObjectId, isSafeObjectIdT, parseObjectId, parseObjectIdFull } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; -import { parseUlid } from '@/misc/id/ulid.js'; +import { parseUlid, parseUlidFull } from '@/misc/id/ulid.js'; @Injectable() export class IdService { @@ -70,4 +70,18 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + // Note: additional is at most 64 bits + @bindThis + public parseFull(id: string): { date: number; additional: bigint; } { + switch (this.method) { + case 'aid': return parseAidFull(id); + case 'aidx': return parseAidxFull(id); + case 'objectid': return parseObjectIdFull(id); + case 'meid': return parseMeidFull(id); + case 'meidg': return parseMeidgFull(id); + case 'ulid': return parseUlidFull(id); + default: throw new Error('unrecognized id generation method'); + } + } } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 00208927e2..28d980f718 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -6,7 +6,7 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { Window, XMLSerializer } from 'happy-dom'; +import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; @@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode']; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; +export type Appender = (document: Document, body: HTMLParagraphElement) => void; + @Injectable() export class MfmService { constructor( @@ -267,7 +269,7 @@ export class MfmService { } @bindThis - public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) { + public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) { if (nodes == null) { return null; } @@ -492,6 +494,10 @@ export class MfmService { appendChildren(nodes, body); + for (const additionalAppender of additionalAppenders) { + additionalAppender(doc, body); + } + // Remove the unnecessary namespace const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*

/, '

'); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8a79908e82..469426f87e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -42,7 +42,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; @@ -199,7 +198,6 @@ export class NoteCreateService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, @@ -534,7 +532,10 @@ export class NoteCreateService implements OnApplicationShutdown { this.pushToTl(note, user); - this.antennaService.addNoteToAntennas(note, user); + this.antennaService.addNoteToAntennas({ + ...note, + channel: data.channel ?? null, + }, user); if (data.reply) { this.saveReply(data.reply, note); @@ -575,38 +576,20 @@ export class NoteCreateService implements OnApplicationShutdown { noteId: note.id, }, { delay, - removeOnComplete: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - // Pack the note const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts deleted file mode 100644 index 181c9f7649..0000000000 --- a/packages/backend/src/core/NoteReadService.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setTimeout } from 'node:timers/promises'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MiUser } from '@/models/User.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiNote } from '@/models/Note.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { trackPromise } from '@/misc/promise-tracker.js'; - -@Injectable() -export class NoteReadService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - } - - @bindThis - public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: { - // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse - isSpecified: boolean; - isMentioned: boolean; - }): Promise { - //#region ミュートしているなら無視 - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - if (mute.map(m => m.muteeId).includes(note.userId)) return; - //#endregion - - // スレッドミュート - const isThreadMuted = await this.noteThreadMutingsRepository.exists({ - where: { - userId: userId, - threadId: note.threadId ?? note.id, - }, - }); - if (isThreadMuted) return; - - const unread = { - id: this.idService.gen(), - noteId: note.id, - userId: userId, - isSpecified: params.isSpecified, - isMentioned: params.isMentioned, - noteUserId: note.userId, - }; - - await this.noteUnreadsRepository.insert(unread); - - // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } }); - - if (!exist) return; - - if (params.isMentioned) { - this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); - } - if (params.isSpecified) { - this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); - } - }, () => { /* aborted, ignore it */ }); - } - - @bindThis - public async read( - userId: MiUser['id'], - notes: (MiNote | Packed<'Note'>)[], - ): Promise { - if (notes.length === 0) return; - - const noteIds = new Set(); - - for (const note of notes) { - if (note.mentions && note.mentions.includes(userId)) { - noteIds.add(note.id); - } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { - noteIds.add(note.id); - } - } - - if (noteIds.size === 0) return; - - // Remove the record - await this.noteUnreadsRepository.delete({ - userId: userId, - noteId: In(Array.from(noteIds)), - }); - - // TODO: ↓まとめてクエリしたい - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isMentioned: true, - }).then(mentionsCount => { - if (mentionsCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); - } - })); - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isSpecified: true, - }).then(specifiedCount => { - if (specifiedCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); - } - })); - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 68ad92f396..eeade4569b 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; +import { ReplyError } from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; @@ -19,7 +20,7 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { UserListService } from '@/core/UserListService.js'; -import type { FilterUnionByProperty } from '@/types.js'; +import { FilterUnionByProperty, groupedNotificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() @@ -145,21 +146,36 @@ export class NotificationService implements OnApplicationShutdown { } } - const notification = { - id: this.idService.gen(), - createdAt: new Date(), - type: type, - ...(notifierId ? { - notifierId, - } : {}), - ...data, - } as any as FilterUnionByProperty; + const createdAt = new Date(); + let notification: FilterUnionByProperty; + let redisId: string; - const redisIdPromise = this.redisClient.xadd( - `notificationTimeline:${notifieeId}`, - 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), - '*', - 'data', JSON.stringify(notification)); + do { + notification = { + id: this.idService.gen(), + createdAt, + type: type, + ...(notifierId ? { + notifierId, + } : {}), + ...data, + } as unknown as FilterUnionByProperty; + + try { + redisId = (await this.redisClient.xadd( + `notificationTimeline:${notifieeId}`, + 'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(), + this.toXListId(notification.id), + 'data', JSON.stringify(notification)))!; + } catch (e) { + // The ID specified in XADD is equal or smaller than the target stream top item で失敗することがあるのでリトライ + if (e instanceof ReplyError) continue; + throw e; + } + + break; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } while (true); const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); @@ -173,7 +189,7 @@ export class NotificationService implements OnApplicationShutdown { const interval = notification.type === 'test' ? 0 : 2000; setTimeout(interval, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); - if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; + if (latestReadNotificationId && (latestReadNotificationId >= redisId)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); @@ -228,6 +244,79 @@ export class NotificationService implements OnApplicationShutdown { this.#shutdownController.abort(); } + private toXListId(id: string): string { + const { date, additional } = this.idService.parseFull(id); + return date.toString() + '-' + additional.toString(); + } + + @bindThis + public async getNotifications( + userId: MiUser['id'], + { + sinceId, + untilId, + limit = 20, + includeTypes, + excludeTypes, + }: { + sinceId?: string, + untilId?: string, + limit?: number, + // any extra types are allowed, those are no-op + includeTypes?: (MiNotification['type'] | string)[], + excludeTypes?: (MiNotification['type'] | string)[], + }, + ): Promise { + let sinceTime = sinceId ? this.toXListId(sinceId) : null; + let untilTime = untilId ? this.toXListId(untilId) : null; + + let notifications: MiNotification[]; + for (;;) { + let notificationsRes: [id: string, fields: string[]][]; + + // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 + if (sinceTime && !untilTime) { + notificationsRes = await this.redisClient.xrange( + `notificationTimeline:${userId}`, + '(' + sinceTime, + '+', + 'COUNT', limit); + } else { + notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + untilTime ? '(' + untilTime : '+', + sinceTime ? '(' + sinceTime : '-', + 'COUNT', limit); + } + + if (notificationsRes.length === 0) { + return []; + } + + notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length !== 0) { + // 通知が1件以上ある場合は返す + break; + } + + // フィルタしたことで通知が0件になった場合、次のページを取得する + if (sinceId && !untilId) { + sinceTime = notificationsRes[notificationsRes.length - 1][0]; + } else { + untilTime = notificationsRes[notificationsRes.length - 1][0]; + } + } + + return notifications; + } + @bindThis public onApplicationShutdown(signal?: string | undefined): void { this.dispose(); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 1479bb00d9..9333c1ebc5 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -22,6 +22,7 @@ type PushNotificationsTypes = { note: Packed<'Note'>; }; 'readAllNotifications': undefined; + newChatMessage: Packed<'ChatMessage'>; }; // Reduce length because push message servers have character limits diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c56a3309a9..9fd04cc629 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -69,7 +69,7 @@ export class QueryService { // ここでいうBlockedは被Blockedの意 @bindThis - public generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { + public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') .select('blocking.blockerId') .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); @@ -137,7 +137,7 @@ export class QueryService { } @bindThis - public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { + public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index da76dd1284..a1e806816b 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import { MetricsTime, type JobType } from 'bullmq'; +import { parse as parseRedisInfo } from 'redis-info'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; @@ -38,6 +40,18 @@ import type { import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; +export const QUEUE_TYPES = [ + 'system', + 'endedPollNotification', + 'deliver', + 'inbox', + 'db', + 'relationship', + 'objectStorage', + 'userWebhookDeliver', + 'systemWebhookDeliver', +] as const; + @Injectable() export class QueueService { constructor( @@ -57,50 +71,58 @@ export class QueueService { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('resyncCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('cleanCharts', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('aggregateRetention', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('clean', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkExpiredMutings', { }, { repeat: { pattern: '*/5 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('bakeBufferedReactions', { }, { repeat: { pattern: '0 0 * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); this.systemQueue.add('checkModeratorsActivity', { }, { // 毎時30分に起動 repeat: { pattern: '30 * * * *' }, - removeOnComplete: true, + removeOnComplete: 10, + removeOnFail: 30, }); } @@ -122,13 +144,21 @@ export class QueueService { isSharedInbox, }; - return this.deliverQueue.add(to, data, { + const label = to.replace('https://', '').replace('/inbox', ''); + + return this.deliverQueue.add(label, data, { attempts: this.config.deliverJobMaxAttempts ?? 12, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -150,12 +180,18 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }; await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({ - name: d[0], + name: d[0].replace('https://', '').replace('/inbox', ''), data: { user, content: contentBody, @@ -176,13 +212,21 @@ export class QueueService { signature, }; - return this.inboxQueue.add('', data, { + const label = (activity.id ?? '').replace('https://', '').replace('/activity', ''); + + return this.inboxQueue.add(label, data, { attempts: this.config.inboxJobMaxAttempts ?? 8, backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -191,8 +235,14 @@ export class QueueService { return this.dbQueue.add('deleteDriveFiles', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -201,8 +251,14 @@ export class QueueService { return this.dbQueue.add('exportCustomEmojis', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -211,8 +267,14 @@ export class QueueService { return this.dbQueue.add('exportNotes', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -221,8 +283,14 @@ export class QueueService { return this.dbQueue.add('exportClips', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -231,8 +299,14 @@ export class QueueService { return this.dbQueue.add('exportFavorites', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -243,8 +317,14 @@ export class QueueService { excludeMuting, excludeInactive, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -253,8 +333,14 @@ export class QueueService { return this.dbQueue.add('exportMuting', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -263,8 +349,14 @@ export class QueueService { return this.dbQueue.add('exportBlocking', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -273,8 +365,14 @@ export class QueueService { return this.dbQueue.add('exportUserLists', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -283,8 +381,14 @@ export class QueueService { return this.dbQueue.add('exportAntennas', { user: { id: user.id }, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -295,8 +399,14 @@ export class QueueService { fileId: fileId, withReplies, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -312,8 +422,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -323,8 +439,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -344,8 +466,14 @@ export class QueueService { name, data, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }, }; } @@ -356,8 +484,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -367,8 +501,14 @@ export class QueueService { user: { id: user.id }, fileId: fileId, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -378,8 +518,14 @@ export class QueueService { user: { id: user.id }, antenna, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -389,8 +535,14 @@ export class QueueService { user: { id: user.id }, soft: opts.soft, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -440,8 +592,14 @@ export class QueueService { withReplies: data.withReplies, }, opts: { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, ...opts, }, }; @@ -452,16 +610,28 @@ export class QueueService { return this.objectStorageQueue.add('deleteFile', { key: key, }, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis public createCleanRemoteFilesJob() { return this.objectStorageQueue.add('cleanRemoteFiles', {}, { - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -492,8 +662,14 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @@ -523,21 +699,201 @@ export class QueueService { backoff: { type: 'custom', }, - removeOnComplete: true, - removeOnFail: true, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, }); } @bindThis - public destroy() { - this.deliverQueue.once('cleaned', (jobs, status) => { - //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); - }); - this.deliverQueue.clean(0, 0, 'delayed'); + private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue { + switch (type) { + case 'system': return this.systemQueue; + case 'endedPollNotification': return this.endedPollNotificationQueue; + case 'deliver': return this.deliverQueue; + case 'inbox': return this.inboxQueue; + case 'db': return this.dbQueue; + case 'relationship': return this.relationshipQueue; + case 'objectStorage': return this.objectStorageQueue; + case 'userWebhookDeliver': return this.userWebhookDeliverQueue; + case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue; + default: throw new Error(`Unrecognized queue type: ${type}`); + } + } - this.inboxQueue.once('cleaned', (jobs, status) => { - //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + @bindThis + public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') { + const queue = this.getQueue(queueType); + + if (state === '*') { + await Promise.all([ + queue.clean(0, 0, 'completed'), + queue.clean(0, 0, 'wait'), + queue.clean(0, 0, 'active'), + queue.clean(0, 0, 'paused'), + queue.clean(0, 0, 'prioritized'), + queue.clean(0, 0, 'delayed'), + queue.clean(0, 0, 'failed'), + ]); + } else { + await queue.clean(0, 0, state); + } + } + + @bindThis + public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + await queue.promoteJobs(); + } + + @bindThis + public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + if (job.finishedOn != null) { + await job.retry(); + } else { + await job.promote(); + } + } + } + + @bindThis + public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + await job.remove(); + } + } + + @bindThis + private packJobData(job: Bull.Job) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : []; + stacktrace.reverse(); + + return { + id: job.id, + name: job.name, + data: job.data, + opts: job.opts, + timestamp: job.timestamp, + processedOn: job.processedOn, + processedBy: job.processedBy, + finishedOn: job.finishedOn, + progress: job.progress, + attempts: job.attemptsMade, + delay: job.delay, + failedReason: job.failedReason, + stacktrace: stacktrace, + returnValue: job.returnvalue, + isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0), + }; + } + + @bindThis + public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { + const queue = this.getQueue(queueType); + const job: Bull.Job | null = await queue.getJob(jobId); + if (job) { + return this.packJobData(job); + } else { + throw new Error(`Job not found: ${jobId}`); + } + } + + @bindThis + public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) { + const RETURN_LIMIT = 100; + const queue = this.getQueue(queueType); + let jobs: Bull.Job[]; + + if (search) { + jobs = await queue.getJobs(jobTypes, 0, 1000); + + jobs = jobs.filter(job => { + const jobString = JSON.stringify(job).toLowerCase(); + return search.toLowerCase().split(' ').every(term => { + return jobString.includes(term); + }); + }); + + jobs = jobs.slice(0, RETURN_LIMIT); + } else { + jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT); + } + + return jobs.map(job => this.packJobData(job)); + } + + @bindThis + public async queueGetQueues() { + const fetchings = QUEUE_TYPES.map(async type => { + const queue = this.getQueue(type); + + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + + return { + name: type, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + }; }); - this.inboxQueue.clean(0, 0, 'delayed'); + + return await Promise.all(fetchings); + } + + @bindThis + public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) { + const queue = this.getQueue(queueType); + const counts = await queue.getJobCounts(); + const isPaused = await queue.isPaused(); + const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK); + const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK); + const db = parseRedisInfo(await (await queue.client).info()); + + return { + name: queueType, + qualifiedName: queue.qualifiedName, + counts: counts, + isPaused, + metrics: { + completed: metrics_completed, + failed: metrics_failed, + }, + db: { + version: db.redis_version, + mode: db.redis_mode, + runId: db.run_id, + processId: db.process_id, + port: parseInt(db.tcp_port), + os: db.os, + uptime: parseInt(db.uptime_in_seconds), + memory: { + total: parseInt(db.total_system_memory) || parseInt(db.maxmemory), + used: parseInt(db.used_memory), + fragmentationRatio: parseInt(db.mem_fragmentation_ratio), + peak: parseInt(db.used_memory_peak), + }, + clients: { + connected: parseInt(db.connected_clients), + blocked: parseInt(db.blocked_clients), + }, + }, + }; } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 01f3e0c116..601959cc96 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -63,6 +63,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; + chatAvailability: 'available' | 'readonly' | 'unavailable'; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, + chatAvailability: 'available', }; @Injectable() @@ -368,6 +370,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value)); } + function aggregateChatAvailability(vs: RolePolicies['chatAvailability'][]) { + if (vs.some(v => v === 'available')) return 'available'; + if (vs.some(v => v === 'readonly')) return 'readonly'; + return 'unavailable'; + } + return { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), @@ -400,6 +408,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), + chatAvailability: calc('chatAvailability', aggregateChatAvailability), }; } @@ -627,6 +636,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { isModerator: values.isModerator, isExplorable: values.isExplorable, asBadge: values.asBadge, + preserveAssignmentOnMoveAccount: values.preserveAssignmentOnMoveAccount, canEditMembersByModerator: values.canEditMembersByModerator, displayOrder: values.displayOrder, policies: values.policies, diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 3004324137..0dd57a8657 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -235,8 +235,8 @@ export class SearchService { this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts index 1e050c3054..53c047dd74 100644 --- a/packages/backend/src/core/SystemAccountService.ts +++ b/packages/backend/src/core/SystemAccountService.ts @@ -5,11 +5,14 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; +import type { OnApplicationShutdown } from '@nestjs/common'; import { DataSource, IsNull } from 'typeorm'; +import * as Redis from 'ioredis'; import bcrypt from 'bcryptjs'; import { MiLocalUser, MiUser } from '@/models/User.js'; import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js'; import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { MemoryKVCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -20,10 +23,13 @@ import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const; @Injectable() -export class SystemAccountService { +export class SystemAccountService implements OnApplicationShutdown { private cache: MemoryKVCache; constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.db) private db: DataSource, @@ -42,6 +48,31 @@ export class SystemAccountService { private idService: IdService, ) { this.cache = new MemoryKVCache(1000 * 60 * 10); // 10m + + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'metaUpdated': { + if (body.before != null && body.before.name !== body.after.name) { + for (const account of SYSTEM_ACCOUNT_TYPES) { + await this.updateCorrespondingUserProfile(account, { + name: body.after.name, + }); + } + } + break; + } + default: + break; + } + } } @bindThis @@ -145,7 +176,7 @@ export class SystemAccountService { @bindThis public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { - name?: string; + name?: string | null; description?: MiUserProfile['description']; }): Promise { const user = await this.fetch(type); @@ -169,4 +200,15 @@ export class SystemAccountService { return updated; } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.cache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index b98ca97ec9..e7a6be99fb 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { IsNull } from 'typeorm'; +import { Brackets, IsNull } from 'typeorm'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -736,4 +736,30 @@ export class UserFollowingService implements OnModuleInit { .where('following.followerId = :followerId', { followerId: userId }) .getMany(); } + + @bindThis + public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { + return this.followingsRepository.exists({ + where: { + followerId, + followeeId, + }, + }); + } + + @bindThis + public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) { + const count = await this.followingsRepository.createQueryBuilder('following') + .where(new Brackets(qb => { + qb.where('following.followerId = :aUserId', { aUserId }) + .andWhere('following.followeeId = :bUserId', { bUserId }); + })) + .orWhere(new Brackets(qb => { + qb.where('following.followerId = :bUserId', { bUserId }) + .andWhere('following.followeeId = :aUserId', { aUserId }); + })) + .getCount(); + + return count === 2; + } } diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts index 0d03cf6ee0..4be7bd9bdb 100644 --- a/packages/backend/src/core/UserSearchService.ts +++ b/packages/backend/src/core/UserSearchService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, SelectQueryBuilder } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js'; +import { type FollowingsRepository, MiUser, type MutingsRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import type { Config } from '@/config.js'; @@ -22,10 +22,19 @@ export class UserSearchService { constructor( @Inject(DI.config) private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + private userEntityService: UserEntityService, ) { } @@ -58,7 +67,7 @@ export class UserSearchService { * @see {@link UserSearchService#buildSearchUserNoLoginQueries} */ @bindThis - public async search( + public async searchByUsernameAndHost( params: { username?: string | null, host?: string | null, @@ -202,4 +211,91 @@ export class UserSearchService { return userQuery; } + + @bindThis + public async search(query: string, meId: MiUser['id'] | null, options: Partial<{ + limit: number; + offset: number; + origin: 'local' | 'remote' | 'combined'; + }> = {}) { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + const isUsername = query.startsWith('@') && !query.includes(' ') && query.indexOf('@', 1) === -1; + + let users: MiUser[] = []; + + const mutingQuery = meId == null ? null : this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + const nameQuery = this.usersRepository.createQueryBuilder('user') + .where(new Brackets(qb => { + qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' }); + + if (isUsername) { + qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(query.replace('@', '').toLowerCase()) + '%' }); + } else if (this.userEntityService.validateLocalUsername(query)) { // Also search username if it qualifies as username + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(query.toLowerCase()) + '%' }); + } + })) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (mutingQuery) { + nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`); + nameQuery.setParameters(mutingQuery.getParameters()); + } + + if (options.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (options.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(options.limit) + .offset(options.offset) + .getMany(); + + if (users.length < (options.limit ?? 30)) { + const profQuery = this.userProfilesRepository.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' }); + + if (mutingQuery) { + profQuery.andWhere(`prof.userId NOT IN (${mutingQuery.getQuery()})`); + profQuery.setParameters(mutingQuery.getParameters()); + } + + if (options.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (options.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); + } + + const userQuery = this.usersRepository.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await userQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(options.limit) + .offset(options.offset) + .getMany(), + ); + } + + return users; + } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index f83dec67bf..9cf985b688 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -7,42 +7,16 @@ import { Injectable } from '@nestjs/common'; import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; -import { AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js'; -import { Packed } from '@/misc/json-schema.js'; +import { type AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { type Packed } from '@/misc/json-schema.js'; import { type WebhookEventTypes } from '@/models/Webhook.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js'; import { QueueService } from '@/core/QueueService.js'; import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; const oneDayMillis = 24 * 60 * 60 * 1000; -function generateAbuseReport(override?: Partial): AbuseReportPayload { - const result: MiAbuseUserReport = { - id: 'dummy-abuse-report1', - targetUserId: 'dummy-target-user', - targetUser: null, - reporterId: 'dummy-reporter-user', - reporter: null, - assigneeId: null, - assignee: null, - resolved: false, - forwarded: false, - comment: 'This is a dummy report for testing purposes.', - targetUserHost: null, - reporterHost: null, - resolvedAs: null, - moderationNote: 'foo', - ...override, - }; - - return { - ...result, - targetUser: result.targetUser ? toPackedUserLite(result.targetUser) : null, - reporter: result.reporter ? toPackedUserLite(result.reporter) : null, - assignee: result.assignee ? toPackedUserLite(result.assignee) : null, - }; -} - function generateDummyUser(override?: Partial): MiUser { return { id: 'dummy-user-1', @@ -79,6 +53,7 @@ function generateDummyUser(override?: Partial): MiUser { requireSigninToViewContents: false, makeNotesFollowersOnlyBefore: null, makeNotesHiddenBefore: null, + chatScope: 'mutual', emojis: [], score: 0, host: null, @@ -134,124 +109,6 @@ function generateDummyNote(override?: Partial): MiNote { }; } -function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> { - return { - id: note.id, - createdAt: new Date().toISOString(), - deletedAt: null, - text: note.text, - cw: note.cw, - userId: note.userId, - user: toPackedUserLite(note.user ?? generateDummyUser()), - replyId: note.replyId, - renoteId: note.renoteId, - isHidden: false, - visibility: note.visibility, - mentions: note.mentions, - visibleUserIds: note.visibleUserIds, - fileIds: note.fileIds, - files: [], - tags: note.tags, - poll: null, - emojis: note.emojis, - channelId: note.channelId, - channel: note.channel, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - reactionEmojis: {}, - reactions: {}, - reactionCount: 0, - renoteCount: note.renoteCount, - repliesCount: note.repliesCount, - uri: note.uri ?? undefined, - url: note.url ?? undefined, - reactionAndUserPairCache: note.reactionAndUserPairCache, - ...(detail ? { - clippedCount: note.clippedCount, - reply: note.reply ? toPackedNote(note.reply, false) : null, - renote: note.renote ? toPackedNote(note.renote, true) : null, - myReaction: null, - } : {}), - ...override, - }; -} - -function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> { - return { - id: user.id, - name: user.name, - username: user.username, - host: user.host, - avatarUrl: user.avatarUrl, - avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations.map(it => ({ - id: it.id, - angle: it.angle, - flipH: it.flipH, - url: 'https://example.com/dummy-image001.png', - offsetX: it.offsetX, - offsetY: it.offsetY, - })), - isBot: user.isBot, - isCat: user.isCat, - emojis: user.emojis, - onlineStatus: 'active', - badgeRoles: [], - ...override, - }; -} - -function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> { - return { - ...toPackedUserLite(user), - url: null, - uri: null, - movedTo: null, - alsoKnownAs: [], - createdAt: new Date().toISOString(), - updatedAt: user.updatedAt?.toISOString() ?? null, - lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - isLocked: user.isLocked, - isSilenced: false, - isSuspended: user.isSuspended, - description: null, - location: null, - birthday: null, - lang: null, - fields: [], - verifiedLinks: [], - followersCount: user.followersCount, - followingCount: user.followingCount, - notesCount: user.notesCount, - pinnedNoteIds: [], - pinnedNotes: [], - pinnedPageId: null, - pinnedPage: null, - publicReactions: true, - followersVisibility: 'public', - followingVisibility: 'public', - twoFactorEnabled: false, - usePasswordLessLogin: false, - securityKeys: false, - roles: [], - memo: null, - moderationNote: undefined, - isFollowing: false, - isFollowed: false, - hasPendingFollowRequestFromYou: false, - hasPendingFollowRequestToYou: false, - isBlocking: false, - isBlocked: false, - isMuted: false, - isRenoteMuted: false, - notify: 'none', - withReplies: true, - ...override, - }; -} - const dummyUser1 = generateDummyUser(); const dummyUser2 = generateDummyUser({ id: 'dummy-user-2', @@ -284,6 +141,7 @@ export class WebhookTestService { }; constructor( + private customEmojiService: CustomEmojiService, private userWebhookService: UserWebhookService, private systemWebhookService: SystemWebhookService, private queueService: QueueService, @@ -354,31 +212,31 @@ export class WebhookTestService { switch (params.type) { case 'note': { - send('note', { note: toPackedNote(dummyNote1) }); + send('note', { note: await this.toPackedNote(dummyNote1) }); break; } case 'reply': { - send('reply', { note: toPackedNote(dummyReply1) }); + send('reply', { note: await this.toPackedNote(dummyReply1) }); break; } case 'renote': { - send('renote', { note: toPackedNote(dummyRenote1) }); + send('renote', { note: await this.toPackedNote(dummyRenote1) }); break; } case 'mention': { - send('mention', { note: toPackedNote(dummyMention1) }); + send('mention', { note: await this.toPackedNote(dummyMention1) }); break; } case 'follow': { - send('follow', { user: toPackedUserDetailedNotMe(dummyUser1) }); + send('follow', { user: await this.toPackedUserDetailedNotMe(dummyUser1) }); break; } case 'followed': { - send('followed', { user: toPackedUserLite(dummyUser2) }); + send('followed', { user: await this.toPackedUserLite(dummyUser2) }); break; } case 'unfollow': { - send('unfollow', { user: toPackedUserDetailedNotMe(dummyUser3) }); + send('unfollow', { user: await this.toPackedUserDetailedNotMe(dummyUser3) }); break; } // まだ実装されていない (#9485) @@ -427,7 +285,7 @@ export class WebhookTestService { switch (params.type) { case 'abuseReport': { - send('abuseReport', generateAbuseReport({ + send('abuseReport', await this.generateAbuseReport({ targetUserId: dummyUser1.id, targetUser: dummyUser1, reporterId: dummyUser2.id, @@ -436,7 +294,7 @@ export class WebhookTestService { break; } case 'abuseReportResolved': { - send('abuseReportResolved', generateAbuseReport({ + send('abuseReportResolved', await this.generateAbuseReport({ targetUserId: dummyUser1.id, targetUser: dummyUser1, reporterId: dummyUser2.id, @@ -448,7 +306,7 @@ export class WebhookTestService { break; } case 'userCreated': { - send('userCreated', toPackedUserLite(dummyUser1)); + send('userCreated', await this.toPackedUserLite(dummyUser1)); break; } case 'inactiveModeratorsWarning': { @@ -474,4 +332,155 @@ export class WebhookTestService { } } } + + @bindThis + private async generateAbuseReport(override?: Partial): Promise { + const result: MiAbuseUserReport = { + id: 'dummy-abuse-report1', + targetUserId: 'dummy-target-user', + targetUser: null, + reporterId: 'dummy-reporter-user', + reporter: null, + assigneeId: null, + assignee: null, + resolved: false, + forwarded: false, + comment: 'This is a dummy report for testing purposes.', + targetUserHost: null, + reporterHost: null, + resolvedAs: null, + moderationNote: 'foo', + ...override, + }; + + return { + ...result, + targetUser: result.targetUser ? await this.toPackedUserLite(result.targetUser) : null, + reporter: result.reporter ? await this.toPackedUserLite(result.reporter) : null, + assignee: result.assignee ? await this.toPackedUserLite(result.assignee) : null, + }; + } + + @bindThis + private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise> { + return { + id: note.id, + createdAt: new Date().toISOString(), + deletedAt: null, + text: note.text, + cw: note.cw, + userId: note.userId, + user: await this.toPackedUserLite(note.user ?? generateDummyUser()), + replyId: note.replyId, + renoteId: note.renoteId, + isHidden: false, + visibility: note.visibility, + mentions: note.mentions, + visibleUserIds: note.visibleUserIds, + fileIds: note.fileIds, + files: [], + tags: note.tags, + poll: null, + emojis: await this.customEmojiService.populateEmojis(note.emojis, note.userHost), + channelId: note.channelId, + channel: note.channel, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, + reactionEmojis: {}, + reactions: {}, + reactionCount: 0, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + reactionAndUserPairCache: note.reactionAndUserPairCache, + ...(detail ? { + clippedCount: note.clippedCount, + reply: note.reply ? await this.toPackedNote(note.reply, false) : null, + renote: note.renote ? await this.toPackedNote(note.renote, true) : null, + myReaction: null, + } : {}), + ...override, + }; + } + + @bindThis + private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise> { + return { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarId == null ? null : user.avatarUrl, + avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.map(it => ({ + id: it.id, + angle: it.angle, + flipH: it.flipH, + url: 'https://example.com/dummy-image001.png', + offsetX: it.offsetX, + offsetY: it.offsetY, + })), + isBot: user.isBot, + isCat: user.isCat, + emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host), + onlineStatus: 'active', + badgeRoles: [], + ...override, + }; + } + + @bindThis + private async toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Promise> { + return { + ...await this.toPackedUserLite(user), + url: null, + uri: null, + movedTo: null, + alsoKnownAs: [], + createdAt: new Date().toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + bannerUrl: user.bannerId == null ? null : user.bannerUrl, + bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: false, + isSuspended: user.isSuspended, + description: null, + location: null, + birthday: null, + lang: null, + fields: [], + verifiedLinks: [], + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPageId: null, + pinnedPage: null, + publicReactions: true, + followersVisibility: 'public', + followingVisibility: 'public', + chatScope: 'mutual', + canChat: true, + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + roles: [], + memo: null, + moderationNote: undefined, + isFollowing: false, + isFollowed: false, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isBlocking: false, + isBlocked: false, + isMuted: false, + isRenoteMuted: false, + notify: 'none', + withReplies: true, + ...override, + }; + } } diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 4036d2794a..f4c07e472c 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import * as mfm from 'mfm-js'; -import { MfmService } from '@/core/MfmService.js'; +import { MfmService, Appender } from '@/core/MfmService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { extractApHashtagObjects } from './models/tag.js'; @@ -25,17 +25,17 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: Pick, apAppend?: string) { + public getNoteHtml(note: Pick, additionalAppender: Appender[] = []) { let noMisskeyContent = false; - const srcMfm = (note.text ?? '') + (apAppend ?? ''); + const srcMfm = (note.text ?? ''); const parsed = mfm.parse(srcMfm); - if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { + if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers)); + const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender); return { content, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index f01874952f..55521d6e3a 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js'; import type { MiPoll } from '@/models/Poll.js'; import type { MiPollVote } from '@/models/PollVote.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { MfmService } from '@/core/MfmService.js'; +import { MfmService, type Appender } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js'; @@ -430,10 +430,24 @@ export class ApRendererService { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - let apAppend = ''; + const apAppend: Appender[] = []; if (quote) { - apAppend += `\n\nRE: ${quote}`; + // Append quote link as `

RE: ...` + // the claas name `quote-inline` is used in non-misskey clients for styling quote notes. + // For compatibility, the span part should be kept as possible. + apAppend.push((doc, body) => { + body.appendChild(doc.createElement('br')); + body.appendChild(doc.createElement('br')); + const span = doc.createElement('span'); + span.className = 'quote-inline'; + span.appendChild(doc.createTextNode('RE: ')); + const link = doc.createElement('a'); + link.setAttribute('href', quote); + link.textContent = quote; + span.appendChild(link); + body.appendChild(span); + }); } const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; @@ -509,7 +523,7 @@ export class ApRendererService { const urlPart = match[0]; const urlPartParsed = new URL(urlPart); const restPart = maybeUrl.slice(match[0].length); - + return `${urlPart}${restPart}`; } catch (e) { return maybeUrl; diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index e770028af3..1f8c8ae3e8 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -41,6 +41,7 @@ export class AntennaEntityService { excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, + excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, isActive: antenna.isActive, hasUnreadNote: false, // TODO notify: false, // 後方互換性のため diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts new file mode 100644 index 0000000000..da112d5444 --- /dev/null +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -0,0 +1,376 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { } from '@/models/Blocking.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { In } from 'typeorm'; + +@Injectable() +export class ChatEntityService { + constructor( + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private idService: IdService, + ) { + } + + @bindThis + public async packMessageDetailed( + src: MiChatMessage['id'] | MiChatMessage, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedFiles?: Map | null>; + packedUsers?: Map>; + packedRooms?: Map | null>; + }; + }, + ): Promise> { + const packedUsers = options?._hint_?.packedUsers; + const packedFiles = options?._hint_?.packedFiles; + const packedRooms = options?._hint_?.packedRooms; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me), + toUserId: message.toUserId, + toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined, + toRoomId: message.toRoomId, + toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesDetailed( + messages: MiChatMessage[], + me: { id: MiUser['id'] }, + ) { + if (messages.length === 0) return []; + + const excludeMe = (x: MiUser | string) => { + if (typeof x === 'string') { + return x !== me.id; + } else { + return x.id !== me.id; + } + }; + + const users = [ + ...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe), + ...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe), + ]; + + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } }))); + } + + @bindThis + public async packMessageLiteFor1on1( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + toUserId: message.toUserId!, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteFor1on1( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const [packedFiles] = await Promise.all([ + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } }))); + } + + @bindThis + public async packMessageLiteForRoom( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + packedUsers: Map>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId), + toRoomId: message.toRoomId!, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteForRoom( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const users = messages.map(x => x.fromUser ?? x.fromUserId); + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles] = await Promise.all([ + this.userEntityService.packMany(users) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } }))); + } + + @bindThis + public async packRoom( + src: MiChatRoom['id'] | MiChatRoom, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedOwners: Map>; + memberships?: Map; + }; + }, + ): Promise> { + const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src }); + + const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null; + + return { + id: room.id, + createdAt: this.idService.parse(room.id).date.toISOString(), + name: room.name, + description: room.description, + ownerId: room.ownerId, + owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me), + isMuted: membership != null ? membership.isMuted : false, + }; + } + + @bindThis + public async packRooms( + rooms: (MiChatRoom | MiChatRoom['id'])[], + me: { id: MiUser['id'] }, + ) { + if (rooms.length === 0) return []; + + const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string'); + if (_rooms.length !== rooms.length) { + _rooms.push( + ...await this.chatRoomsRepository.find({ + where: { + id: In(rooms.filter((room): room is string => typeof room === 'string')), + }, + relations: ['owner'], + }), + ); + } + + const owners = _rooms.map(x => x.owner ?? x.ownerId); + + const [packedOwners, memberships] = await Promise.all([ + this.userEntityService.packMany(owners, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.chatRoomMembershipsRepository.find({ + where: { + roomId: In(_rooms.map(x => x.id)), + userId: me.id, + }, + }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))), + ]); + + return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } }))); + } + + @bindThis + public async packRoomInvitation( + src: MiChatRoomInvitation['id'] | MiChatRoomInvitation, + me: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedRooms: Map>; + packedUsers: Map>; + }; + }, + ): Promise> { + const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + createdAt: this.idService.parse(invitation.id).date.toISOString(), + roomId: invitation.roomId, + room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me), + userId: invitation.userId, + user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me), + }; + } + + @bindThis + public async packRoomInvitations( + invitations: MiChatRoomInvitation[], + me: { id: MiUser['id'] }, + ) { + if (invitations.length === 0) return []; + + return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me))); + } + + @bindThis + public async packRoomMembership( + src: MiChatRoomMembership['id'] | MiChatRoomMembership, + me: { id: MiUser['id'] }, + options?: { + populateUser?: boolean; + populateRoom?: boolean; + _hint_?: { + packedRooms: Map>; + packedUsers: Map>; + }; + }, + ): Promise> { + const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src }); + + return { + id: membership.id, + createdAt: this.idService.parse(membership.id).date.toISOString(), + userId: membership.userId, + user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined, + roomId: membership.roomId, + room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined, + }; + } + + @bindThis + public async packRoomMemberships( + memberships: MiChatRoomMembership[], + me: { id: MiUser['id'] }, + options: { + populateUser?: boolean; + populateRoom?: boolean; + } = {}, + ) { + if (memberships.length === 0) return []; + + const users = memberships.map(x => x.user ?? x.userId); + const rooms = memberships.map(x => x.room ?? x.roomId); + + const [packedUsers, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.packRooms(rooms, me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } }))); + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 08717bd066..02783dc450 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -127,6 +127,7 @@ export class MetaEntityService { policies: { ...DEFAULT_POLICIES, ...instance.policies }, + sentryForFrontend: this.config.sentryForFrontend ?? null, mediaProxy: this.config.mediaProxy, enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index dff6968f9c..e91fb9eb51 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js'; import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js'; import { CacheService } from '@/core/CacheService.js'; import { RoleEntityService } from './RoleEntityService.js'; +import { ChatEntityService } from './ChatEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; @@ -27,6 +28,7 @@ export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private roleEntityService: RoleEntityService; + private chatEntityService: ChatEntityService; constructor( private moduleRef: ModuleRef, @@ -41,9 +43,6 @@ export class NotificationEntityService implements OnModuleInit { private followRequestsRepository: FollowRequestsRepository, private cacheService: CacheService, - - //private userEntityService: UserEntityService, - //private noteEntityService: NoteEntityService, ) { } @@ -51,6 +50,7 @@ export class NotificationEntityService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService'); + this.chatEntityService = this.moduleRef.get('ChatEntityService'); } /** @@ -59,7 +59,6 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - options: { checkValidNotifier?: boolean; }, @@ -92,7 +91,7 @@ export class NotificationEntityService implements OnModuleInit { // if the user has been deleted, don't show this notification if (needsUser && !userIfNeed) return null; - // #region Grouped notifications + //#region Grouped notifications if (notification.type === 'reaction:grouped') { const reactions = (await Promise.all(notification.reactions.map(async reaction => { const user = hint?.packedUsers != null @@ -137,7 +136,7 @@ export class NotificationEntityService implements OnModuleInit { users, }); } - // #endregion + //#endregion const needsRole = notification.type === 'roleAssigned'; const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined; @@ -146,6 +145,13 @@ export class NotificationEntityService implements OnModuleInit { return null; } + const needsChatRoomInvitation = notification.type === 'chatRoomInvitationReceived'; + const chatRoomInvitation = needsChatRoomInvitation ? await this.chatEntityService.packRoomInvitation(notification.invitationId, { id: meId }).catch(() => null) : undefined; + // if the invitation has been deleted, don't show this notification + if (needsChatRoomInvitation && !chatRoomInvitation) { + return null; + } + return await awaitAll({ id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), @@ -159,6 +165,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'roleAssigned' ? { role: role, } : {}), + ...(notification.type === 'chatRoomInvitationReceived' ? { + invitation: chatRoomInvitation, + } : {}), ...(notification.type === 'followRequestAccepted' ? { message: notification.message, } : {}), @@ -236,7 +245,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 2a7dc37bce..3fa38c9521 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -13,6 +13,7 @@ import type { MiRole } from '@/models/Role.js'; import { bindThis } from '@/decorators.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; +import { Packed } from '@/misc/json-schema.js'; @Injectable() export class RoleEntityService { @@ -31,7 +32,7 @@ export class RoleEntityService { public async pack( src: MiRole['id'] | MiRole, me?: { id: MiUser['id'] } | null | undefined, - ) { + ): Promise> { const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') @@ -67,6 +68,7 @@ export class RoleEntityService { isModerator: role.isModerator, isExplorable: role.isExplorable, asBadge: role.asBadge, + preserveAssignmentOnMoveAccount: role.preserveAssignmentOnMoveAccount, canEditMembersByModerator: role.canEditMembersByModerator, displayOrder: role.displayOrder, policies: policies, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 69f698d9cb..d4769d24d4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -32,7 +32,6 @@ import type { MiUserNotePining, MiUserProfile, MutingsRepository, - NoteUnreadsRepository, RenoteMutingsRepository, UserMemoRepository, UserNotePiningsRepository, @@ -48,9 +47,9 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; -import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; const Ajv = _Ajv.default; @@ -94,6 +93,7 @@ export class UserEntityService implements OnModuleInit { private federatedInstanceService: FederatedInstanceService; private idService: IdService; private avatarDecorationService: AvatarDecorationService; + private chatService: ChatService; constructor( private moduleRef: ModuleRef, @@ -128,9 +128,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -152,6 +149,7 @@ export class UserEntityService implements OnModuleInit { this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); + this.chatService = this.moduleRef.get('ChatService'); } //#region Validators @@ -488,8 +486,8 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, - avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), - avatarBlurhash: user.avatarBlurhash, + avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user), + avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash), avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ id: ud.id, angle: ud.angle || undefined, @@ -535,8 +533,8 @@ export class UserEntityService implements OnModuleInit { createdAt: this.idService.parse(user.id).date.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, + bannerUrl: user.bannerId == null ? null : user.bannerUrl, + bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, isLocked: user.isLocked, isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended, @@ -558,6 +556,8 @@ export class UserEntityService implements OnModuleInit { publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, + chatScope: user.chatScope, + canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'), roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, @@ -598,14 +598,9 @@ export class UserEntityService implements OnModuleInit { isDeleted: user.isDeleted, twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ - where: { userId: user.id, isSpecified: true }, - take: 1, - }).then(count => count > 0), - hasUnreadMentions: this.noteUnreadsRepository.count({ - where: { userId: user.id, isMentioned: true }, - take: 1, - }).then(count => count > 0), + hasUnreadSpecifiedNotes: false, // 後方互換性のため + hasUnreadMentions: false, // 後方互換性のため + hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id), hasUnreadAnnouncement: unreadAnnouncements!.length > 0, unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index cdfb4a6fe2..a46e85fc83 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -25,7 +25,6 @@ export const DI = { noteMutingsRepository: Symbol('noteMutingsRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), - noteUnreadsRepository: Symbol('noteUnreadsRepository'), pollsRepository: Symbol('pollsRepository'), pollVotesRepository: Symbol('pollVotesRepository'), userProfilesRepository: Symbol('userProfilesRepository'), @@ -84,6 +83,11 @@ export const DI = { flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), userMemosRepository: Symbol('userMemosRepository'), + chatMessagesRepository: Symbol('chatMessagesRepository'), + chatApprovalsRepository: Symbol('chatApprovalsRepository'), + chatRoomsRepository: Symbol('chatRoomsRepository'), + chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'), + chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), //#endregion diff --git a/packages/backend/src/misc/FileWriterStream.ts b/packages/backend/src/misc/FileWriterStream.ts index 367a8eb560..27c67cb5df 100644 --- a/packages/backend/src/misc/FileWriterStream.ts +++ b/packages/backend/src/misc/FileWriterStream.ts @@ -4,6 +4,7 @@ */ import * as fs from 'node:fs/promises'; +import { WritableStream } from 'node:stream/web'; import type { PathLike } from 'node:fs'; /** diff --git a/packages/backend/src/misc/bigint.ts b/packages/backend/src/misc/bigint.ts new file mode 100644 index 0000000000..efa1527ec9 --- /dev/null +++ b/packages/backend/src/misc/bigint.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +function parseBigIntChunked(str: string, base: number, chunkSize: number, powerOfChunkSize: bigint): bigint { + const chunks = []; + while (str.length > 0) { + chunks.unshift(str.slice(-chunkSize)); + str = str.slice(0, -chunkSize); + } + let result = 0n; + for (const chunk of chunks) { + result *= powerOfChunkSize; + const int = parseInt(chunk, base); + if (Number.isNaN(int)) { + throw new Error('Invalid base36 string'); + } + result += BigInt(int); + } + return result; +} + +export function parseBigInt36(str: string): bigint { + // log_36(Number.MAX_SAFE_INTEGER) => 10.251599391715352 + // so we process 10 chars at once + return parseBigIntChunked(str, 36, 10, 36n ** 10n); +} + +export function parseBigInt16(str: string): bigint { + // log_16(Number.MAX_SAFE_INTEGER) => 13.25 + // so we process 13 chars at once + return parseBigIntChunked(str, 16, 13, 16n ** 13n); +} + +export function parseBigInt32(str: string): bigint { + // log_32(Number.MAX_SAFE_INTEGER) => 10.6 + // so we process 10 chars at once + return parseBigIntChunked(str, 32, 10, 32n ** 10n); +} diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 60ba788e44..c0e8478db5 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -7,6 +7,7 @@ // 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列] import * as crypto from 'node:crypto'; +import { parseBigInt36 } from '@/misc/bigint.js'; export const aidRegExp = /^[0-9a-z]{10}$/; @@ -35,6 +36,12 @@ export function parseAid(id: string): { date: Date; } { return { date: new Date(time) }; } +export function parseAidFull(id: string): { date: number; additional: bigint; } { + const date = parseInt(id.slice(0, 8), 36) + TIME2000; + const additional = parseBigInt36(id.slice(8, 10)); + return { date, additional }; +} + export function isSafeAidT(t: number): boolean { return t > TIME2000; } diff --git a/packages/backend/src/misc/id/aidx.ts b/packages/backend/src/misc/id/aidx.ts index 1b087e70af..006673a6d0 100644 --- a/packages/backend/src/misc/id/aidx.ts +++ b/packages/backend/src/misc/id/aidx.ts @@ -9,6 +9,7 @@ // https://misskey.m544.net/notes/71899acdcc9859ec5708ac24 import { customAlphabet } from 'nanoid'; +import { parseBigInt36 } from '@/misc/bigint.js'; export const aidxRegExp = /^[0-9a-z]{16}$/; @@ -16,6 +17,7 @@ const TIME2000 = 946684800000; const TIME_LENGTH = 8; const NODE_LENGTH = 4; const NOISE_LENGTH = 4; +const AIDX_LENGTH = TIME_LENGTH + NODE_LENGTH + NOISE_LENGTH; const nodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', NODE_LENGTH)(); let counter = 0; @@ -42,6 +44,12 @@ export function parseAidx(id: string): { date: Date; } { return { date: new Date(time) }; } +export function parseAidxFull(id: string): { date: number; additional: bigint; } { + const date = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000; + const additional = parseBigInt36(id.slice(TIME_LENGTH, AIDX_LENGTH)); + return { date, additional }; +} + export function isSafeAidxT(t: number): boolean { return t > TIME2000; } diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index dfab48a369..563e07ed8f 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // same as object-id @@ -39,6 +41,13 @@ export function parseMeid(id: string): { date: Date; } { }; } +export function parseMeidFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(0, 12), 16) - 0x800000000000, + additional: parseBigInt16(id.slice(12, 24)), + }; +} + export function isSafeMeidT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index b9c0cc3dda..b825807114 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' @@ -39,6 +41,13 @@ export function parseMeidg(id: string): { date: Date; } { }; } +export function parseMeidgFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(1, 12), 16), + additional: parseBigInt16(id.slice(12, 24)), + }; +} + export function isSafeMeidgT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 243f92bbac..68409c7a61 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { parseBigInt16 } from '@/misc/bigint.js'; + const CHARS = '0123456789abcdef'; // same as meid @@ -39,6 +41,13 @@ export function parseObjectId(id: string): { date: Date; } { }; } +export function parseObjectIdFull(id: string): { date: number; additional: bigint; } { + return { + date: parseInt(id.slice(0, 8), 16) * 1000, + additional: parseBigInt16(id.slice(8, 24)), + }; +} + export function isSafeObjectIdT(t: number): boolean { return t > 0; } diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts index fc3654d6d2..8b81702d19 100644 --- a/packages/backend/src/misc/id/ulid.ts +++ b/packages/backend/src/misc/id/ulid.ts @@ -5,15 +5,27 @@ // Crockford's Base32 // https://github.com/ulid/spec#encoding +import { parseBigInt32 } from '@/misc/bigint.js'; + const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; -export function parseUlid(id: string): { date: Date; } { - const timestamp = id.slice(0, 10); +function parseBase32(timestamp: string) { let time = 0; - for (let i = 0; i < 10; i++) { + for (let i = 0; i < timestamp.length; i++) { time = time * 32 + CHARS.indexOf(timestamp[i]); } - return { date: new Date(time) }; + return time; +} + +export function parseUlid(id: string): { date: Date; } { + return { date: new Date(parseBase32(id.slice(0, 10))) }; +} + +export function parseUlidFull(id: string): { date: number; additional: bigint; } { + return { + date: parseBase32(id.slice(0, 10)), + additional: parseBigInt32(id.slice(10, 26)), + }; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ac74d68c95..27aa3d89de 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -63,6 +63,10 @@ import { } from '@/models/json-schema/meta.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; +import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessageLiteForRoomSchema, packedChatMessageLiteFor1on1Schema } from '@/models/json-schema/chat-message.js'; +import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; +import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; +import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -120,6 +124,13 @@ export const refs = { MetaDetailed: packedMetaDetailedSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, + ChatMessage: packedChatMessageSchema, + ChatMessageLite: packedChatMessageLiteSchema, + ChatMessageLiteFor1on1: packedChatMessageLiteFor1on1Schema, + ChatMessageLiteForRoom: packedChatMessageLiteForRoomSchema, + ChatRoom: packedChatRoomSchema, + ChatRoomInvitation: packedChatRoomInvitationSchema, + ChatRoomMembership: packedChatRoomMembershipSchema, }; export type Packed = SchemaType; @@ -166,15 +177,16 @@ export interface Schema extends OfSchema { readonly maximum?: number; readonly minimum?: number; readonly pattern?: string; + readonly additionalProperties?: Schema | boolean; } type RequiredPropertyNames = { [K in keyof s]: - // K is not optional - s[K]['optional'] extends false ? K : - // K has default value - s[K]['default'] extends null | string | number | boolean | Record ? K : - never + // K is not optional + s[K]['optional'] extends false ? K : + // K has default value + s[K]['default'] extends null | string | number | boolean | Record ? K : + never }[keyof s]; export type Obj = Record; @@ -213,11 +225,18 @@ type ObjectSchemaTypeDef

= p['anyOf'] extends ReadonlyArray ? p['anyOf'][number]['required'] extends ReadonlyArray ? UnionObjType> & ObjType> : never - : ObjType> - : - p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md - p['allOf'] extends ReadonlyArray ? UnionToIntersection> : - any; + : ObjType> + : + p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md + p['allOf'] extends ReadonlyArray ? UnionToIntersection> : + p['additionalProperties'] extends true ? Record : + p['additionalProperties'] extends Schema ? + p['additionalProperties'] extends infer AdditionalProperties ? + AdditionalProperties extends Schema ? + Record> : + never : + never : + any; type ObjectSchemaType

= NullOrUndefined>; @@ -227,30 +246,30 @@ export type SchemaTypeDef

= p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( p['enum'] extends readonly (string | null)[] ? - p['enum'][number] : - p['format'] extends 'date-time' ? string : // Dateにする?? - string + p['enum'][number] : + p['format'] extends 'date-time' ? string : // Dateにする?? + string ) : - p['type'] extends 'boolean' ? boolean : - p['type'] extends 'object' ? ObjectSchemaTypeDef

: - p['type'] extends 'array' ? ( - p['items'] extends OfSchema ? ( - p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : - p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : - p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : - never + p['type'] extends 'boolean' ? boolean : + p['type'] extends 'object' ? ObjectSchemaTypeDef

: + p['type'] extends 'array' ? ( + p['items'] extends OfSchema ? ( + p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : + p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : + p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : + never + ) : + p['prefixItems'] extends ReadonlyArray ? ( + p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : + p['items'] extends false ? ArrayToTuple : + p['unevaluatedItems'] extends false ? ArrayToTuple : + [...ArrayToTuple, ...unknown[]] + ) : + p['items'] extends NonNullable ? SchemaType[] : + any[] ) : - p['prefixItems'] extends ReadonlyArray ? ( - p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : - p['items'] extends false ? ArrayToTuple : - p['unevaluatedItems'] extends false ? ArrayToTuple : - [...ArrayToTuple, ...unknown[]] - ) : - p['items'] extends NonNullable ? SchemaType[] : - any[] - ) : - p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : - p['oneOf'] extends ReadonlyArray ? UnionSchemaType : - any; + p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : + p['oneOf'] extends ReadonlyArray ? UnionSchemaType : + any; export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 33e6f48189..17ec0c0f79 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -100,4 +100,9 @@ export class MiAntenna { default: false, }) public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public excludeNotesInSensitiveChannel: boolean; } diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts new file mode 100644 index 0000000000..55c9f07e9a --- /dev/null +++ b/packages/backend/src/models/ChatApproval.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('chat_approval') +@Index(['userId', 'otherId'], { unique: true }) +export class MiChatApproval { + @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 otherId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public other: MiUser | null; +} diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts new file mode 100644 index 0000000000..3d2b64268e --- /dev/null +++ b/packages/backend/src/models/ChatMessage.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiDriveFile } from './DriveFile.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_message') +export class MiChatMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public fromUserId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public fromUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toUserId: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toRoomId: MiChatRoom['id'] | null; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toRoom: MiChatRoom | null; + + @Column('varchar', { + length: 4096, nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}', + }) + public reads: MiUser['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: MiDriveFile['id'] | null; + + @ManyToOne(type => MiDriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public file: MiDriveFile | null; + + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public reactions: string[]; +} diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts new file mode 100644 index 0000000000..ad2a910b78 --- /dev/null +++ b/packages/backend/src/models/ChatRoom.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('chat_room') +export class MiChatRoom { + @PrimaryColumn(id()) + public id: string; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + }) + public ownerId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public owner: MiUser | null; + + @Column('varchar', { + length: 2048, default: '', + }) + public description: string; + + @Column('boolean', { + default: false, + }) + public isArchived: boolean; +} diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts new file mode 100644 index 0000000000..36ce12bc92 --- /dev/null +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_invitation') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomInvitation { + @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 roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; + + @Column('boolean', { + default: false, + }) + public ignored: boolean; +} diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts new file mode 100644 index 0000000000..3cb5524859 --- /dev/null +++ b/packages/backend/src/models/ChatRoomMembership.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_membership') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomMembership { + @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 roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; + + @Column('boolean', { + default: false, + }) + public isMuted: boolean; +} diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab..c5ca2b5776 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -10,6 +10,7 @@ import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; import type { MiDriveFile } from './DriveFile.js'; +@Index(['userId', 'id']) @Entity('note') export class MiNote { @PrimaryColumn(id()) @@ -65,7 +66,6 @@ export class MiNote { }) public cw: string | null; - @Index() @Column({ ...id(), comment: 'The ID of author.', diff --git a/packages/backend/src/models/NoteUnread.ts b/packages/backend/src/models/NoteUnread.ts deleted file mode 100644 index c759181117..0000000000 --- a/packages/backend/src/models/NoteUnread.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiNote } from './Note.js'; -import type { MiChannel } from './Channel.js'; - -@Entity('note_unread') -@Index(['userId', 'noteId'], { unique: true }) -export class MiNoteUnread { - @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 noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - /** - * メンションか否か - */ - @Index() - @Column('boolean') - public isMentioned: boolean; - - /** - * ダイレクト投稿か否か - */ - @Index() - @Column('boolean') - public isSpecified: boolean; - - //#region Denormalized fields - @Index() - @Column({ - ...id(), - comment: '[Denormalized]', - }) - public noteUserId: MiUser['id']; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: '[Denormalized]', - }) - public noteChannelId: MiChannel['id'] | null; - //#endregion -} diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 5772ace338..5764a307b0 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -75,6 +75,12 @@ export type MiNotification = { id: string; createdAt: string; roleId: MiRole['id']; +} | { + type: 'chatRoomInvitationReceived'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + invitationId: string; } | { type: 'achievementEarned'; id: string; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index a89f14d5a2..701090e33f 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -43,7 +43,6 @@ import { MiNoteMuting, MiNoteReaction, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, @@ -79,6 +78,11 @@ import { MiUserPublickey, MiUserSecurityKey, MiWebhook, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, } from './_.js'; import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; @@ -143,12 +147,6 @@ const $noteReactionsRepository: Provider = { inject: [DI.db], }; -const $noteUnreadsRepository: Provider = { - provide: DI.noteUnreadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteUnread).extend(miRepository as MiRepository), - inject: [DI.db], -}; - const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -295,7 +293,7 @@ const $swSubscriptionsRepository: Provider = { const $systemAccountsRepository: Provider = { provide: DI.systemAccountsRepository, - useFactory: (db: DataSource) => db.getRepository(MiSystemAccount), + useFactory: (db: DataSource) => db.getRepository(MiSystemAccount).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -313,7 +311,7 @@ const $abuseUserReportsRepository: Provider = { const $abuseReportNotificationRecipientRepository: Provider = { provide: DI.abuseReportNotificationRecipientRepository, - useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient), + useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -445,7 +443,7 @@ const $webhooksRepository: Provider = { const $systemWebhooksRepository: Provider = { provide: DI.systemWebhooksRepository, - useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook), + useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -497,6 +495,36 @@ const $userMemosRepository: Provider = { inject: [DI.db], }; +const $chatMessagesRepository: Provider = { + provide: DI.chatMessagesRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatMessage).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomsRepository: Provider = { + provide: DI.chatRoomsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoom).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomMembershipsRepository: Provider = { + provide: DI.chatRoomMembershipsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomMembership).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomInvitationsRepository: Provider = { + provide: DI.chatRoomInvitationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatApprovalsRepository: Provider = { + provide: DI.chatApprovalsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $bubbleGameRecordsRepository: Provider = { provide: DI.bubbleGameRecordsRepository, useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository), @@ -522,7 +550,6 @@ const $reversiGamesRepository: Provider = { $noteMutingsRepository, $noteThreadMutingsRepository, $noteReactionsRepository, - $noteUnreadsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -581,6 +608,11 @@ const $reversiGamesRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $chatMessagesRepository, + $chatRoomsRepository, + $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, + $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, ], @@ -595,7 +627,6 @@ const $reversiGamesRepository: Provider = { $noteMutingsRepository, $noteThreadMutingsRepository, $noteReactionsRepository, - $noteUnreadsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -654,6 +685,11 @@ const $reversiGamesRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $chatMessagesRepository, + $chatRoomsRepository, + $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, + $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, ], diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index a173971b2c..4c7da252bd 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -248,6 +248,11 @@ export class MiRole { }) public isExplorable: boolean; + @Column('boolean', { + default: false, + }) + public preserveAssignmentOnMoveAccount: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 630240efde..baf4eefdf1 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -118,21 +118,25 @@ export class MiUser { @JoinColumn() public banner: MiDriveFile | null; + // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { length: 512, nullable: true, }) public avatarUrl: string | null; + // bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること @Column('varchar', { length: 512, nullable: true, }) public bannerUrl: string | null; + // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { length: 128, nullable: true, }) public avatarBlurhash: string | null; + // bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること @Column('varchar', { length: 128, nullable: true, }) @@ -225,6 +229,17 @@ export class MiUser { }) public emojis: string[]; + // チャットを許可する相手 + // everyone: 誰からでも + // followers: フォロワーのみ + // following: フォローしているユーザーのみ + // mutual: 相互フォローのみ + // none: 誰からも受け付けない + @Column('varchar', { + length: 128, default: 'mutual', + }) + public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none'; + @Index() @Column('varchar', { length: 128, nullable: true, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 474eccf530..70c6eb1ee8 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,32 +3,48 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; -import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { + FindOneOptions, + InsertQueryBuilder, + ObjectLiteral, + QueryRunner, + Repository, + SelectQueryBuilder, +} from 'typeorm'; +import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; -import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; -import { OrmUtils } from 'typeorm/util/OrmUtils.js'; -import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { + RawSqlResultsToEntityTransformer, +} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; +import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; import { MiApp } from '@/models/App.js'; -import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; +import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiBlocking } from '@/models/Blocking.js'; -import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +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 { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; import { MiClip } from '@/models/Clip.js'; -import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; +import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiFlash } from '@/models/Flash.js'; +import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -38,12 +54,11 @@ import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; -import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; +import { MiNoteMuting } from '@/models/NoteMuting.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; -import { MiNoteUnread } from '@/models/NoteUnread.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -54,38 +69,38 @@ import { MiPromoRead } from '@/models/PromoRead.js'; import { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { MiRegistryItem } from '@/models/RegistryItem.js'; import { MiRelay } from '@/models/Relay.js'; +import { MiRenoteMuting } from '@/models/RenoteMuting.js'; +import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; +import { MiReversiGame } from '@/models/ReversiGame.js'; +import { MiRole } from '@/models/Role.js'; +import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiSignin } from '@/models/Signin.js'; import { MiSwSubscription } from '@/models/SwSubscription.js'; import { MiSystemAccount } from '@/models/SystemAccount.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiUsedUsername } from '@/models/UsedUsername.js'; import { MiUser } from '@/models/User.js'; import { MiUserIp } from '@/models/UserIp.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserList } from '@/models/UserList.js'; +import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListMembership } from '@/models/UserListMembership.js'; +import { MiUserMemo } from '@/models/UserMemo.js'; import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; -import { MiUserMemo } from '@/models/UserMemo.js'; import { MiWebhook } from '@/models/Webhook.js'; -import { MiSystemWebhook } from '@/models/SystemWebhook.js'; -import { MiChannel } from '@/models/Channel.js'; -import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; -import { MiRole } from '@/models/Role.js'; -import { MiRoleAssignment } from '@/models/RoleAssignment.js'; -import { MiFlash } from '@/models/Flash.js'; -import { MiFlashLike } from '@/models/FlashLike.js'; -import { MiUserListFavorite } from '@/models/UserListFavorite.js'; -import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; -import { MiReversiGame } from '@/models/ReversiGame.js'; -import { MiNoteMuting } from './NoteMuting.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository { createTableColumnNames(this: Repository & MiRepository): string[]; + insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise; + + insertOneImpl(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>, queryRunner?: QueryRunner): Promise; + selectAliasColumnNames(this: Repository & MiRepository, queryBuilder: InsertQueryBuilder, builder: SelectQueryBuilder): void; } @@ -94,6 +109,21 @@ export const miRepository = { return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName); }, async insertOne(entity, findOptions?) { + const opt = this.manager.connection.options as PostgresConnectionOptions; + if (opt.replication) { + const queryRunner = this.manager.connection.createQueryRunner('master'); + try { + return this.insertOneImpl(entity, findOptions, queryRunner); + } finally { + await queryRunner.release(); + } + } else { + return this.insertOneImpl(entity, findOptions); + } + }, + async insertOneImpl(entity, findOptions?, queryRunner?) { + // ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ---- + const queryBuilder = this.createQueryBuilder().insert().values(entity); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mainAlias = queryBuilder.expressionMap.mainAlias!; @@ -101,7 +131,9 @@ export const miRepository = { mainAlias.name = 't'; const columnNames = this.createTableColumnNames(); queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); - const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames }); + + // ---- 共通テーブル式(CTE)から結果を取得 ---- + const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion builder.expressionMap.mainAlias!.tablePath = 'cte'; this.selectAliasColumnNames(queryBuilder, builder); @@ -161,7 +193,6 @@ export { MiNoteReaction, MiNoteMuting, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, @@ -196,12 +227,19 @@ export { MiFlash, MiFlashLike, MiUserMemo, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, MiBubbleGameRecord, MiReversiGame, }; export type AbuseUserReportsRepository = Repository & MiRepository; -export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; +export type AbuseReportNotificationRecipientRepository = + Repository + & MiRepository; export type AccessTokensRepository = Repository & MiRepository; export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; @@ -234,7 +272,6 @@ export type NoteFavoritesRepository = Repository & MiRepository< export type NoteReactionsRepository = Repository & MiRepository; export type NoteMutingsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; -export type NoteUnreadsRepository = Repository & MiRepository; export type PagesRepository = Repository & MiRepository; export type PageLikesRepository = Repository & MiRepository; export type PasswordResetRequestsRepository = Repository & MiRepository; @@ -269,5 +306,10 @@ export type RoleAssignmentsRepository = Repository & MiReposit export type FlashsRepository = Repository & MiRepository; export type FlashLikesRepository = Repository & MiRepository; export type UserMemoRepository = Repository & MiRepository; +export type ChatMessagesRepository = Repository & MiRepository; +export type ChatRoomsRepository = Repository & MiRepository; +export type ChatRoomMembershipsRepository = Repository & MiRepository; +export type ChatRoomInvitationsRepository = Repository & MiRepository; +export type ChatApprovalsRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index b5b9a5b42c..eca7563066 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -100,5 +100,10 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + excludeNotesInSensitiveChannel: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts new file mode 100644 index 0000000000..3b5e85ab69 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-message.ts @@ -0,0 +1,256 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatMessageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toUser: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + toRoomId: { + type: 'string', + optional: true, nullable: true, + }, + toRoom: { + type: 'object', + optional: true, nullable: true, + ref: 'ChatRoom', + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toRoomId: { + type: 'string', + optional: true, nullable: true, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteFor1on1Schema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + toUserId: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteForRoomSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + toRoomId: { + type: 'string', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room-invitation.ts b/packages/backend/src/models/json-schema/chat-room-invitation.ts new file mode 100644 index 0000000000..204c959b2c --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room-invitation.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomInvitationSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + roomId: { + type: 'string', + optional: false, nullable: false, + }, + room: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room-membership.ts b/packages/backend/src/models/json-schema/chat-room-membership.ts new file mode 100644 index 0000000000..adb73f9dde --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room-membership.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomMembershipSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + roomId: { + type: 'string', + optional: false, nullable: false, + }, + room: { + type: 'object', + optional: true, nullable: false, + ref: 'ChatRoom', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room.ts b/packages/backend/src/models/json-schema/chat-room.ts new file mode 100644 index 0000000000..e97556e378 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + ownerId: { + type: 'string', + optional: false, nullable: false, + }, + owner: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + isMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 1e25c355ca..2cd7620af0 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -211,6 +211,38 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + sentryForFrontend: { + type: 'object', + optional: false, nullable: true, + properties: { + options: { + type: 'object', + optional: false, nullable: false, + properties: { + dsn: { + type: 'string', + optional: false, nullable: false, + }, + }, + additionalProperties: true, + }, + vueIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + browserTracingIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + replayIntegration: { + type: 'object', + optional: true, nullable: true, + additionalProperties: true, + }, + }, + }, mediaProxy: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 1638b2b3c7..7f23d2d6a1 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -287,6 +287,21 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['chatRoomInvitationReceived'], + }, + invitation: { + type: 'object', + ref: 'ChatRoomInvitation', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c8..1cfcb830e0 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -292,6 +292,11 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + chatAvailability: { + type: 'string', + optional: false, nullable: false, + enum: ['available', 'readonly', 'unavailable'], + }, }, } as const; @@ -385,6 +390,11 @@ export const packedRoleSchema = { optional: false, nullable: false, example: false, }, + preserveAssignmentOnMoveAccount: { + type: 'boolean', + optional: false, nullable: false, + example: false, + }, canEditMembersByModerator: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 38631f907d..e475296702 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -358,6 +358,15 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, enum: ['public', 'followers', 'private'], }, + chatScope: { + type: 'string', + nullable: false, optional: false, + enum: ['everyone', 'following', 'followers', 'mutual', 'none'], + }, + canChat: { + type: 'boolean', + nullable: false, optional: false, + }, roles: { type: 'array', nullable: false, optional: false, @@ -540,6 +549,10 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + hasUnreadChatMessages: { + type: 'boolean', + nullable: false, optional: false, + }, hasUnreadNotification: { type: 'boolean', nullable: false, optional: false, @@ -599,6 +612,7 @@ export const packedMeDetailedOnlySchema = { receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, + chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index d9edc277c3..10be2fbf4e 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -5,10 +5,13 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -import { DataSource, Logger } from 'typeorm'; +import { DataSource, Logger, type QueryRunner } from 'typeorm'; import * as highlight from 'cli-highlight'; import { MiNoteMuting } from '@/models/NoteMuting.js'; import { entities as charts } from '@/core/chart/entities.js'; +import { Config } from '@/config.js'; +import MisskeyLogger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; @@ -43,7 +46,6 @@ import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; -import { MiNoteUnread } from '@/models/NoteUnread.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -77,13 +79,14 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; - -import { Config } from '@/config.js'; -import MisskeyLogger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; -import { MiSystemAccount } from './models/SystemAccount.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiSystemAccount } from '@/models/SystemAccount.js'; pg.types.setTypeParser(20, Number); @@ -94,6 +97,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); export type LoggerProps = { disableQueryTruncation?: boolean; enableQueryParamLogging?: boolean; + printReplicationMode?: boolean, }; function highlightSql(sql: string) { @@ -119,8 +123,10 @@ class MyCustomLogger implements Logger { } @bindThis - private transformQueryLog(sql: string) { - let modded = sql; + private transformQueryLog(sql: string, opts?: { + prefix?: string; + }) { + let modded = opts?.prefix ? opts.prefix + sql : sql; if (!this.props.disableQueryTruncation) { modded = truncateSql(modded); } @@ -138,18 +144,27 @@ class MyCustomLogger implements Logger { } @bindThis - public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis - public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis - public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); + public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const prefix = (this.props.printReplicationMode && queryRunner) + ? `[${queryRunner.getReplicationMode()}] ` + : undefined; + sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); } @bindThis @@ -196,7 +211,6 @@ export const entities = [ MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, - MiNoteUnread, MiNoteMuting, MiPage, MiPageLike, @@ -238,6 +252,11 @@ export const entities = [ MiFlash, MiFlashLike, MiUserMemo, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, MiBubbleGameRecord, MiReversiGame, ...charts, @@ -293,6 +312,7 @@ export function createPostgresDataSource(config: Config) { ? new MyCustomLogger({ disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + printReplicationMode: !!config.dbReplications, }) : undefined, maxQueryExecutionTime: 300, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 6940e1c188..c98ebcdcd9 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -44,7 +44,7 @@ import { BakeBufferedReactionsProcessorService } from './processors/BakeBuffered import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; -import { QUEUE, baseQueueOptions } from './const.js'; +import { QUEUE, baseWorkerOptions } from './const.js'; // ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 function httpRelatedBackoff(attemptsMade: number) { @@ -175,7 +175,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM), autorun: false, }); @@ -232,7 +232,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DB), + ...baseWorkerOptions(this.config, QUEUE.DB), autorun: false, }); @@ -264,7 +264,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.deliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.DELIVER), + ...baseWorkerOptions(this.config, QUEUE.DELIVER), autorun: false, concurrency: this.config.deliverJobConcurrency ?? 128, limiter: { @@ -304,7 +304,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.inboxProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.INBOX), + ...baseWorkerOptions(this.config, QUEUE.INBOX), autorun: false, concurrency: this.config.inboxJobConcurrency ?? 16, limiter: { @@ -344,7 +344,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.userWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, concurrency: 64, limiter: { @@ -384,7 +384,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.systemWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), + ...baseWorkerOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), autorun: false, concurrency: 16, limiter: { @@ -434,7 +434,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), + ...baseWorkerOptions(this.config, QUEUE.RELATIONSHIP), autorun: false, concurrency: this.config.relationshipJobConcurrency ?? 16, limiter: { @@ -479,7 +479,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return processer(job); } }, { - ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), + ...baseWorkerOptions(this.config, QUEUE.OBJECT_STORAGE), autorun: false, concurrency: 16, }); @@ -512,7 +512,7 @@ export class QueueProcessorService implements OnApplicationShutdown { return this.endedPollNotificationProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), + ...baseWorkerOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), autorun: false, }); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 67f689b618..7e146a7e03 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { MetricsTime } from 'bullmq'; import { Config } from '@/config.js'; import type * as Bull from 'bullmq'; @@ -27,3 +28,12 @@ export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof t prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`, }; } + +export function baseWorkerOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.WorkerOptions { + return { + ...baseQueueOptions(config, queueName), + metrics: { + maxDataPoints: MetricsTime.ONE_WEEK, + }, + }; +} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 48c80e5e61..f7b22c44c4 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -32,6 +32,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; import * as Acct from '@/misc/acct.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; @@ -75,6 +76,7 @@ export class ActivityPubServerService { private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { //this.createServer = this.createServer.bind(this); } @@ -461,16 +463,28 @@ export class ActivityPubServerService { const partOf = `${this.config.url}/users/${userId}/outbox`; if (page) { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) - .andWhere('note.userId = :userId', { userId: user.id }) - .andWhere(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - .andWhere('note.localOnly = FALSE'); - - const notes = await query.limit(limit).getMany(); + const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({ + sinceId: sinceId ?? null, + untilId: untilId ?? null, + limit: limit, + allowPartial: false, // Possibly true? IDK it's OK for ordered collection. + me: null, + redisTimelines: [ + `userTimeline:${user.id}`, + `userTimelineWithReplies:${user.id}`, + ], + useDbFallback: true, + ignoreAuthorFromMute: true, + excludePureRenotes: false, + noteFilter: (note) => { + if (note.visibility !== 'home' && note.visibility !== 'public') return false; + if (note.localOnly) return false; + return true; + }, + dbFallback: async (untilId, sinceId, limit) => { + return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id); + }, + }) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id); if (sinceId) notes.reverse(); @@ -508,6 +522,20 @@ export class ActivityPubServerService { } } + @bindThis + private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) { + return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId }) + .andWhere(new Brackets(qb => { + qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE') + .limit(limit) + .getMany(); + } + @bindThis private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { if (this.meta.federation === 'none') { @@ -735,7 +763,7 @@ export class ActivityPubServerService { const acct = Acct.parse(request.params.acct); const user = await this.usersRepository.findOneBy({ - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: acct.host ?? IsNull(), isSuspended: false, }); diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 3ab0b815f2..0223650329 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -44,6 +44,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; +import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @@ -84,6 +86,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j GlobalTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, + ChatUserChannelService, + ChatRoomChannelService, ReversiChannelService, ReversiGameChannelService, HomeTimelineChannelService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index b899053287..355d7ca08e 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -221,7 +221,7 @@ export class ServerService implements OnApplicationShutdown { reply.header('Cache-Control', 'public, max-age=86400'); if (user) { - reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); + reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user)); } else { reply.redirect('/static-assets/user-unknown.png'); } diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index d106be5bc8..ebfd1a421d 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -138,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => !acct.host || acct.host === this.config.host.toLowerCase() ? { - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: IsNull(), isSuspended: false, } : 422; diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 9399aa61b0..a42fdaf730 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -391,10 +391,10 @@ export class ApiCallService implements OnApplicationShutdown { } } - if (ep.meta.requireRolePolicy != null && (this.meta.rootUserId !== user!.id)) { + if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); const policies = await this.roleService.getUserPolicies(user!.id); - if (!policies[ep.meta.requireRolePolicy] && !myRoles.some(r => r.isAdministrator)) { + if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a required role.', code: 'ROLE_PERMISSION_DENIED', diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index b8f448477b..2a4e1fc574 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -9,7 +9,6 @@ import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; @@ -35,7 +34,6 @@ export class StreamingApiServerService { private usersRepository: UsersRepository, private cacheService: CacheService, - private noteReadService: NoteReadService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, private notificationService: NotificationService, @@ -96,7 +94,6 @@ export class StreamingApiServerService { const stream = new MainStreamConnection( this.channelsService, - this.noteReadService, this.notificationService, this.cacheService, this.channelFollowingService, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 4f3696fc91..eef69ee186 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -67,8 +67,14 @@ export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js'; -export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js'; +export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js'; +export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js'; +export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js'; +export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js'; +export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js'; export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js'; +export * as 'admin/queue/queues' from './endpoints/admin/queue/queues.js'; +export * as 'admin/queue/queue-stats' from './endpoints/admin/queue/queue-stats.js'; export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; export * as 'admin/relays/remove' from './endpoints/admin/relays/remove.js'; @@ -263,7 +269,6 @@ export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped. export * as 'i/page-likes' from './endpoints/i/page-likes.js'; export * as 'i/pages' from './endpoints/i/pages.js'; export * as 'i/pin' from './endpoints/i/pin.js'; -export * as 'i/read-all-unread-notes' from './endpoints/i/read-all-unread-notes.js'; export * as 'i/read-announcement' from './endpoints/i/read-announcement.js'; export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js'; export * as 'i/registry/get' from './endpoints/i/registry/get.js'; @@ -400,4 +405,28 @@ export * as 'users/search' from './endpoints/users/search.js'; export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js'; export * as 'users/show' from './endpoints/users/show.js'; export * as 'users/update-memo' from './endpoints/users/update-memo.js'; +export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js'; +export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js'; +export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js'; +export * as 'chat/messages/show' from './endpoints/chat/messages/show.js'; +export * as 'chat/messages/react' from './endpoints/chat/messages/react.js'; +export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.js'; +export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js'; +export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js'; +export * as 'chat/messages/search' from './endpoints/chat/messages/search.js'; +export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js'; +export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js'; +export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js'; +export * as 'chat/rooms/leave' from './endpoints/chat/rooms/leave.js'; +export * as 'chat/rooms/mute' from './endpoints/chat/rooms/mute.js'; +export * as 'chat/rooms/show' from './endpoints/chat/rooms/show.js'; +export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js'; +export * as 'chat/rooms/joining' from './endpoints/chat/rooms/joining.js'; +export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js'; +export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js'; +export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js'; +export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js'; +export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js'; +export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js'; +export * as 'chat/history' from './endpoints/chat/history.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 7f4ca9c0e0..03c729ed18 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -39,7 +39,7 @@ interface IEndpointMetaBase { */ readonly requireAdmin?: boolean; - readonly requireRolePolicy?: KeyOf<'RolePolicies'>; + readonly requiredRolePolicy?: KeyOf<'RolePolicies'>; /** * 引っ越し済みのユーザーによるリクエストを禁止するか diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts index 87d80cbe80..0121c302ac 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts index 3a5673d99d..13660d0b8c 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', errors: { }, diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts index d785f085ac..d4d9a7235b 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'read:admin:avatar-decorations', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts index 34b3b5a11f..22476a6888 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index a30a080e59..1459351d37 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 53256565f6..3852146177 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 87b58ff6f6..cf03859ce5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -17,7 +17,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index cec9f700c3..7993edcc07 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 50c45b6ac5..87ed3f5f18 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index 8e5f69c894..7ca931eb21 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -10,7 +10,7 @@ import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index 0889ceb76f..b44007962d 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index ffb5dbf4b5..4342e178cc 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index 0fa119eabe..161c3b9f37 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index d9ee18699c..2e700809d8 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index dc25df2767..ee87858b0e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts index 4ba99faab7..7ab5916951 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index e3aaa051c1..6834a6d213 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts index 3f7df0e63d..81cb4b8119 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -18,8 +18,11 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, - required: [], + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'string', enum: ['*', 'completed', 'wait', 'active', 'paused', 'prioritized', 'delayed', 'failed'] }, + }, + required: ['queue', 'state'], } as const; @Injectable() @@ -29,7 +32,7 @@ export default class extends Endpoint { // eslint- private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.destroy(); + this.queueService.queueClear(ps.queue, ps.state); this.moderationLogService.log(me, 'clearQueue'); }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..79731c9786 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,38 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } }, + search: { type: 'string' }, + }, + required: ['queue', 'state'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetJobs(ps.queue, ps.state, ps.search); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts new file mode 100644 index 0000000000..d22385e261 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote-jobs.ts @@ -0,0 +1,39 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queuePromoteJobs(ps.queue); + + this.moderationLogService.log(me, 'promoteQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts deleted file mode 100644 index 7502d4e1f7..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; -import { QueueService } from '@/core/QueueService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, - kind: 'write:admin:queue', -} as const; - -export const paramDef = { - type: 'object', - properties: { - type: { type: 'string', enum: ['deliver', 'inbox'] }, - }, - required: ['type'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - private moderationLogService: ModerationLogService, - private queueService: QueueService, - ) { - super(meta, paramDef, async (ps, me) => { - let delayedQueues; - - switch (ps.type) { - case 'deliver': - delayedQueues = await this.queueService.deliverQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - - case 'inbox': - delayedQueues = await this.queueService.inboxQueue.getDelayed(); - for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { - const queue = delayedQueues[queueIndex]; - try { - await queue.promote(); - } catch (e) { - if (e instanceof Error) { - if (e.message.indexOf('not in a delayed state') !== -1) { - throw e; - } - } else { - throw e; - } - } - } - break; - } - - this.moderationLogService.log(me, 'promoteQueue'); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts new file mode 100644 index 0000000000..10ce48332a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts @@ -0,0 +1,36 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + }, + required: ['queue'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetQueue(ps.queue); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts new file mode 100644 index 0000000000..3a38275f60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts @@ -0,0 +1,35 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetQueues(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts new file mode 100644 index 0000000000..2c73f689d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/remove-job.ts @@ -0,0 +1,38 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queueRemoveJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts new file mode 100644 index 0000000000..b2603128f8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/retry-job.ts @@ -0,0 +1,38 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.queueRetryJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts new file mode 100644 index 0000000000..63747b5540 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts @@ -0,0 +1,38 @@ +/* + * 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 { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:queue', +} as const; + +export const paramDef = { + type: 'object', + properties: { + queue: { type: 'string', enum: QUEUE_TYPES }, + jobId: { type: 'string' }, + }, + required: ['queue', 'jobId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + return this.queueService.queueGetJob(ps.queue, ps.jobId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index e0c02f7a5d..f92f7ebaeb 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -36,6 +36,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility asBadge: { type: 'boolean' }, + preserveAssignmentOnMoveAccount: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, policies: { diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 465ad7aaaf..175adcb63f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -41,6 +41,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, isExplorable: { type: 'boolean' }, asBadge: { type: 'boolean' }, + preserveAssignmentOnMoveAccount: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, policies: { @@ -78,6 +79,7 @@ export default class extends Endpoint { // eslint- isAdministrator: ps.isAdministrator, isExplorable: ps.isExplorable, asBadge: ps.asBadge, + preserveAssignmentOnMoveAccount: ps.preserveAssignmentOnMoveAccount, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, policies: ps.policies, diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 655bd32bce..1ba6853dbe 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -106,6 +106,7 @@ export const meta = { receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, + chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index e0c8ddcc84..c075608491 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -73,6 +73,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @@ -133,6 +134,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 87b063457a..dd2f7469e4 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -8,7 +8,6 @@ import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; @@ -59,9 +58,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -71,7 +67,6 @@ export default class extends Endpoint { // eslint- private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, - private noteReadService: NoteReadService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, ) { @@ -113,9 +108,12 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 + // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 + this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); const notes = await query.getMany(); @@ -125,8 +123,6 @@ export default class extends Endpoint { // eslint- notes.sort((a, b) => a.id > b.id ? -1 : 1); } - this.noteReadService.read(me.id, notes); - return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 10f26b1912..53fc4db1b7 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -72,6 +72,7 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, + excludeNotesInSensitiveChannel: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -129,6 +130,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, + excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 8b0ba5af79..16169e3a81 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -122,8 +122,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } //#endregion diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts new file mode 100644 index 0000000000..fdd9055106 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/history.ts @@ -0,0 +1,75 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + room: { type: 'boolean', default: false }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); + + const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); + + if (ps.room) { + const roomIds = history.map(m => m.toRoomId!); + const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds); + + for (const message of packedMessages) { + message.isRead = readStateMap[message.toRoomId!] ?? false; + } + } else { + const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!); + const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds); + + for (const message of packedMessages) { + const otherId = message.fromUserId === me.id ? message.toUserId! : message.fromUserId!; + message.isRead = readStateMap[otherId] ?? false; + } + } + + return packedMessages; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts new file mode 100644 index 0000000000..ad2b82e219 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteForRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '340517b7-6d04-42c0-bac1-37ee804e3594', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + toRoomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['toRoomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findRoomById(ps.toRoomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await this.chatService.createMessageToRoom(me, room, { + text: ps.text, + file: file, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts new file mode 100644 index 0000000000..fa34a7d558 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteFor1on1', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c', + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + toUserId: { type: 'string', format: 'misskey:id' }, + }, + required: ['toUserId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + // Myself + if (ps.toUserId === me.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + const toUser = await this.getterService.getUser(ps.toUserId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + return await this.chatService.createMessageToUser(me, toUser, { + text: ps.text, + file: file, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts new file mode 100644 index 0000000000..63b75fb6a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts @@ -0,0 +1,54 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '36b67f0e-66a6-414b-83df-992a55294f17', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const message = await this.chatService.findMyMessageById(me.id, ps.messageId); + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + await this.chatService.deleteMessage(message); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts new file mode 100644 index 0000000000..5f61e7e992 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts @@ -0,0 +1,51 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '9b5839b9-0ba0-4351-8c35-37082093d200', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, + }, + required: ['messageId', 'reaction'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.react(ps.messageId, me.id, ps.reaction); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts new file mode 100644 index 0000000000..c0e344b889 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts @@ -0,0 +1,75 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteForRoom', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId); + + this.chatService.readRoomChatMessage(me.id, room.id); + + return await this.chatEntityService.packMessagesLiteForRoom(messages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts new file mode 100644 index 0000000000..682597f76d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts @@ -0,0 +1,78 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '460b3669-81b0-4dc9-a997-44442141bf83', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 256 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + roomId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: ['query'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + if (ps.roomId != null) { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!(await this.chatService.isRoomMember(room, me.id))) { + throw new ApiError(meta.errors.noSuchRoom); + } + } + + const messages = await this.chatService.searchMessages(me.id, ps.query, ps.limit, { + userId: ps.userId, + roomId: ps.roomId, + }); + + return await this.chatEntityService.packMessagesDetailed(messages, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts new file mode 100644 index 0000000000..9a2bbb8742 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts @@ -0,0 +1,65 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '3710865b-1848-4da9-8d61-cfed15510b93', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private roleService: RoleService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const message = await this.chatService.findMessageById(ps.messageId); + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) { + throw new ApiError(meta.errors.noSuchMessage); + } + return this.chatEntityService.packMessageDetailed(message, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts new file mode 100644 index 0000000000..6784bb6ecf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts @@ -0,0 +1,51 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: 'c39ea42f-e3ca-428a-ad57-390e0a711595', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, + }, + required: ['messageId', 'reaction'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.unreact(ps.messageId, me.id, ps.reaction); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts new file mode 100644 index 0000000000..a057e2e088 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts @@ -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 { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLiteFor1on1', + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const other = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId); + + this.chatService.readUserChatMessage(me.id, other.id); + + return await this.chatEntityService.packMessagesLiteFor1on1(messages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts new file mode 100644 index 0000000000..68a53f0886 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 10, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 256 }, + description: { type: 'string', maxLength: 1024 }, + }, + required: ['name'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.createRoom(me, { + name: ps.name, + description: ps.description ?? '', + }); + return await this.chatEntityService.packRoom(room); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts new file mode 100644 index 0000000000..82a8e1f30d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts @@ -0,0 +1,59 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) { + throw new ApiError(meta.errors.noSuchRoom); + } + + await this.chatService.deleteRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts new file mode 100644 index 0000000000..b1f049f2b9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 50, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '916f9507-49ba-4e90-b57f-1fd4deaa47a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId); + return await this.chatEntityService.packRoomInvitation(invitation, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts new file mode 100644 index 0000000000..b8a228089b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts @@ -0,0 +1,50 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '5130557e-5a11-4cfb-9cc5-fe60cda5de0d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.ignoreRoomInvitation(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts new file mode 100644 index 0000000000..8a02d1c704 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts @@ -0,0 +1,56 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRoomInvitations(invitations, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts new file mode 100644 index 0000000000..0702ba086c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'a3c6b309-9717-4316-ae94-a69b53437237', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRoomInvitations(invitations, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts new file mode 100644 index 0000000000..d561f9e03f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts @@ -0,0 +1,50 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '84416476-5ce8-4a2c-b568-9569f1b10733', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.joinToRoom(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts new file mode 100644 index 0000000000..ba9242c762 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts @@ -0,0 +1,60 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomMembership', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); + + return this.chatEntityService.packRoomMemberships(memberships, me, { + populateUser: false, + populateRoom: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts new file mode 100644 index 0000000000..a3ad0c2d6f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts @@ -0,0 +1,50 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'cb7f3179-50e8-4389-8c30-dbe2650a67c9', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.leaveRoom(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts new file mode 100644 index 0000000000..f5ffa21d32 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts @@ -0,0 +1,76 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomMembership', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '7b9fe84c-eafc-4d21-bf89-485458ed2c18', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!(await this.chatService.isRoomMember(room, me.id))) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId); + + return this.chatEntityService.packRoomMemberships(memberships, me, { + populateUser: true, + populateRoom: false, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts new file mode 100644 index 0000000000..11cbe7b8b9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts @@ -0,0 +1,51 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'c2cde4eb-8d0f-42f1-8f2f-c4d6bfc8e5df', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + mute: { type: 'boolean' }, + }, + required: ['roomId', 'mute'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + await this.chatService.muteRoom(me.id, ps.roomId, ps.mute); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts new file mode 100644 index 0000000000..accf7e1bee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts @@ -0,0 +1,56 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRooms(rooms, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts new file mode 100644 index 0000000000..50da210d81 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts @@ -0,0 +1,60 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '857ae02f-8759-4d20-9adb-6e95fffe4fd7', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'read'); + + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + return this.chatEntityService.packRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts new file mode 100644 index 0000000000..0cd62cb040 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts @@ -0,0 +1,67 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'fcdb0f92-bda6-47f9-bd05-343e0e020932', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', maxLength: 256 }, + description: { type: 'string', maxLength: 1024 }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.checkChatAvailability(me.id, 'write'); + + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const updated = await this.chatService.updateRoom(room, { + name: ps.name, + description: ps.description, + }); + + return this.chatEntityService.packRoom(updated, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index b8723e4b69..94489023cd 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -87,8 +87,8 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index bdf6c065e8..ccec96ffbb 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -16,7 +16,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportAntennas', + requiredRolePolicy: 'canImportAntennas', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index d7bb6bcd22..2fa450558b 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportBlocking', + requiredRolePolicy: 'canImportBlocking', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index e03192d8c6..9186fca162 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportFollowing', + requiredRolePolicy: 'canImportFollowing', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 76b285bb7e..b6dbacd371 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportMuting', + requiredRolePolicy: 'canImportMuting', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 76ecfd082c..5de0a70bbb 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportUserLists', + requiredRolePolicy: 'canImportUserLists', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index dc6ffd3e02..b9c41b057d 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -7,9 +7,13 @@ import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js'; +import { + obsoleteNotificationTypes, + groupedNotificationTypes, + FilterUnionByProperty, + notificationTypes, +} from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; @@ -48,10 +52,10 @@ export const paramDef = { markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { - type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, excludeTypes: { type: 'array', items: { - type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes], + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], } }, }, required: [], @@ -63,13 +67,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { const EXTRA_LIMIT = 100; @@ -79,31 +79,20 @@ export default class extends Endpoint { // eslint- return []; } // excludeTypes に全指定されている場合はクエリしない - if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) { + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { return []; } const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; - const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', - 'COUNT', limit); - - if (notificationsRes.length === 0) { - return []; - } - - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; - - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } + const notifications = await this.notificationService.getNotifications(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + limit: ps.limit, + includeTypes, + excludeTypes, + }); if (notifications.length === 0) { return []; @@ -162,14 +151,6 @@ export default class extends Endpoint { // eslint- } groupedNotifications = groupedNotifications.slice(0, ps.limit); - const noteIds = groupedNotifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId!); - - if (noteIds.length > 0) { - const notes = await this.notesRepository.findBy({ id: In(noteIds) }); - this.noteReadService.read(me.id, notes); - } return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); }); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 2f619380e9..f5a48b2f69 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; @@ -69,7 +68,6 @@ export default class extends Endpoint { // eslint- private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { // includeTypes が空の場合はクエリしない @@ -84,67 +82,19 @@ export default class extends Endpoint { // eslint- const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null; - let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null; - - let notifications: MiNotification[]; - for (;;) { - let notificationsRes: [id: string, fields: string[]][]; - - // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 - if (sinceTime && !untilTime) { - notificationsRes = await this.redisClient.xrange( - `notificationTimeline:${me.id}`, - '(' + sinceTime, - '+', - 'COUNT', ps.limit); - } else { - notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - untilTime ? '(' + untilTime : '+', - sinceTime ? '(' + sinceTime : '-', - 'COUNT', ps.limit); - } - - if (notificationsRes.length === 0) { - return []; - } - - notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; - - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } - - if (notifications.length !== 0) { - // 通知が1件以上ある場合は返す - break; - } - - // フィルタしたことで通知が0件になった場合、次のページを取得する - if (ps.sinceId && !ps.untilId) { - sinceTime = notificationsRes[notificationsRes.length - 1][0]; - } else { - untilTime = notificationsRes[notificationsRes.length - 1][0]; - } - } + const notifications = await this.notificationService.getNotifications(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + limit: ps.limit, + includeTypes, + excludeTypes, + }); // Mark all as read if (ps.markAsRead) { this.notificationService.readAllNotification(me.id); } - const noteIds = notifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId); - - if (noteIds.length > 0) { - const notes = await this.notesRepository.findBy({ id: In(noteIds) }); - this.noteReadService.read(me.id, notes); - } - return await this.notificationEntityService.packMany(notifications, me.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts deleted file mode 100644 index d1a8eccb1d..0000000000 --- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { NoteUnreadsRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account'], - - requireCredential: true, - - kind: 'write:account', -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // Remove documents - await this.noteUnreadsRepository.delete({ - userId: me.id, - }); - - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions'); - this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes'); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 4c72879b73..082d97f5d4 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -190,6 +190,7 @@ export const paramDef = { autoSensitive: { type: 'boolean' }, followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, mutedWords: muteWords, hardMutedWords: muteWords, @@ -211,6 +212,7 @@ export const paramDef = { receiveFollowRequest: notificationRecieveConfig, followRequestAccepted: notificationRecieveConfig, roleAssigned: notificationRecieveConfig, + chatRoomInvitationReceived: notificationRecieveConfig, achievementEarned: notificationRecieveConfig, app: notificationRecieveConfig, test: notificationRecieveConfig, @@ -288,6 +290,7 @@ export default class extends Endpoint { // eslint- if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; + if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { // TODO: ちゃんと数える diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts index a70b587da7..f2e683ddf2 100644 --- a/packages/backend/src/server/api/endpoints/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/invite/create.ts @@ -18,7 +18,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'write:invite-codes', errors: { diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts index e960ff9f4e..06f47e90bc 100644 --- a/packages/backend/src/server/api/endpoints/invite/delete.ts +++ b/packages/backend/src/server/api/endpoints/invite/delete.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'write:invite-codes', errors: { diff --git a/packages/backend/src/server/api/endpoints/invite/limit.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts index 2ffd41ae28..0067dce231 100644 --- a/packages/backend/src/server/api/endpoints/invite/limit.ts +++ b/packages/backend/src/server/api/endpoints/invite/limit.ts @@ -15,7 +15,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'read:invite-codes', res: { diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts index 23aefe83a2..a99974a91e 100644 --- a/packages/backend/src/server/api/endpoints/invite/list.ts +++ b/packages/backend/src/server/api/endpoints/invite/list.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'read:invite-codes', res: { diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index ae2b57911c..aa1bd358fc 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -71,8 +71,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 771c1e8793..a502afa11e 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -79,8 +79,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index f60fb65334..c8266f592f 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -243,8 +243,8 @@ export default class extends Endpoint { // eslint- } this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index a91f92b41d..e8847329dd 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -157,8 +157,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 9e9092bb8b..e8fb23f84c 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -9,7 +9,6 @@ import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -52,7 +51,6 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { const followingQuery = this.followingsRepository.createQueryBuilder('following') @@ -74,9 +72,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); if (ps.visibility) { @@ -90,8 +88,6 @@ export default class extends Endpoint { // eslint- const mentions = await query.limit(ps.limit).getMany(); - this.noteReadService.read(me.id, mentions); - return await this.noteEntityService.packMany(mentions, me); }); } diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index a385d3dbfb..ca349cc1e0 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -73,8 +73,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 973794fb79..4a863810a7 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -57,8 +57,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 2d18d1bbf1..ee25d35b5d 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -82,8 +82,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 732d644a29..29c6aa7434 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -9,7 +9,6 @@ import type { NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -52,7 +51,6 @@ export default class extends Endpoint { // eslint- private noteThreadMutingsRepository: NoteThreadMutingsRepository, private getterService: GetterService, - private noteReadService: NoteReadService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { @@ -69,8 +67,6 @@ export default class extends Endpoint { // eslint- }], }); - await this.noteReadService.read(me.id, mutedNotes); - await this.noteThreadMutingsRepository.insert({ id: this.idService.gen(), threadId: note.threadId ?? note.id, diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index a929b7dc04..5fae3bf36a 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -199,8 +199,8 @@ export default class extends Endpoint { // eslint- })); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index cf0c236cb2..123d35a9f6 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -184,8 +184,8 @@ export default class extends Endpoint { // eslint- })); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index a7c39e3368..f994a023c1 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -102,8 +102,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); const notes = await query.getMany(); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index ad474f50a0..bb1335c5d4 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -185,8 +185,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedNoteQuery(query, me); } diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 5b3b4527f7..5b1c6b514b 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -63,7 +63,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForUsers(query, me); this.queryService.generateBlockQueryForUsers(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 8ff952dcb5..134f1a8e87 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -46,7 +46,7 @@ export default class extends Endpoint { // eslint- private userSearchService: UserSearchService, ) { super(meta, paramDef, (ps, me) => { - return this.userSearchService.search({ + return this.userSearchService.searchByUsernameAndHost({ username: ps.username, host: ps.host, }, { diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index 0b0136066d..5d36847e03 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -3,14 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; export const meta = { tags: ['users'], @@ -45,79 +42,15 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - private userEntityService: UserEntityService, + private userSearchService: UserSearchService, ) { super(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - ps.query = ps.query.trim(); - const isUsername = ps.query.startsWith('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1; - - let users: MiUser[] = []; - - const nameQuery = this.usersRepository.createQueryBuilder('user') - .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (isUsername) { - qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }); - } else if (this.userEntityService.validateLocalUsername(ps.query)) { // Also search username if it qualifies as username - qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); - } - })) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); - - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } - - users = await nameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - - if (users.length < ps.limit) { - const profQuery = this.userProfilesRepository.createQueryBuilder('prof') - .select('prof.userId') - .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (ps.origin === 'local') { - profQuery.andWhere('prof.userHost IS NULL'); - } else if (ps.origin === 'remote') { - profQuery.andWhere('prof.userHost IS NOT NULL'); - } - - const query = this.usersRepository.createQueryBuilder('user') - .where(`user.id IN (${ profQuery.getQuery() })`) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE') - .setParameters(profQuery.getParameters()); - - users = users.concat(await query - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(), - ); - } + const users = await this.userSearchService.search(ps.query.trim(), me?.id ?? null, { + offset: ps.offset, + limit: ps.limit, + origin: ps.origin, + }); return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); }); diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts index 9426318e34..7139715293 100644 --- a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts @@ -12,7 +12,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 253409259f..c0ef589dea 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -19,6 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js'; +import { ChatUserChannelService } from './channels/chat-user.js'; +import { ChatRoomChannelService } from './channels/chat-room.js'; import { ReversiChannelService } from './channels/reversi.js'; import { ReversiGameChannelService } from './channels/reversi-game.js'; import { type MiChannelService } from './channel.js'; @@ -40,6 +42,8 @@ export class ChannelsService { private serverStatsChannelService: ServerStatsChannelService, private queueStatsChannelService: QueueStatsChannelService, private adminChannelService: AdminChannelService, + private chatUserChannelService: ChatUserChannelService, + private chatRoomChannelService: ChatRoomChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, ) { @@ -62,6 +66,8 @@ export class ChannelsService { case 'serverStats': return this.serverStatsChannelService; case 'queueStats': return this.queueStatsChannelService; case 'admin': return this.adminChannelService; + case 'chatUser': return this.chatUserChannelService; + case 'chatRoom': return this.chatRoomChannelService; case 'reversi': return this.reversiChannelService; case 'reversiGame': return this.reversiGameChannelService; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 2be168d917..bbc8634c6b 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -7,7 +7,6 @@ import * as WebSocket from 'ws'; import type { MiUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; @@ -47,7 +46,6 @@ export default class Connection { constructor( private channelsService: ChannelsService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, @@ -124,7 +122,7 @@ export default class Connection { case 'readNotification': this.onReadNotification(body); break; case 'subNote': this.onSubscribeNote(body); break; case 's': this.onSubscribeNote(body); break; // alias - case 'sr': this.onSubscribeNote(body); this.readNote(body); break; + case 'sr': this.onSubscribeNote(body); break; case 'unsubNote': this.onUnsubscribeNote(body); break; case 'un': this.onUnsubscribeNote(body); break; // alias case 'connect': this.onChannelConnectRequested(body); break; @@ -159,19 +157,6 @@ export default class Connection { if (note.renote) add(note.renote); } - @bindThis - private readNote(body: JsonValue | undefined) { - if (!isJsonObject(body)) return; - const id = body.id; - - const note = this.cachedNotes.find(n => n.id === id); - if (note == null) return; - - if (this.user && (note.userId !== this.user.id)) { - this.noteReadService.read(this.user.id, [note]); - } - } - @bindThis private onReadNotification(payload: JsonValue | undefined) { this.notificationService.readAllNotification(this.user!.id); diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts new file mode 100644 index 0000000000..eda333dd30 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import { ChatService } from '@/core/ChatService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ChatRoomChannel extends Channel { + public readonly chName = 'chatRoom'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private roomId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.roomId !== 'string') return; + this.roomId = params.roomId; + + this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chatRoom']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.roomId) { + this.chatService.readRoomChatMessage(this.user!.id, this.roomId); + } + break; + } + } + + @bindThis + public dispose() { + this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent); + } +} + +@Injectable() +export class ChatRoomChannelService implements MiChannelService { + public readonly shouldShare = ChatRoomChannel.shouldShare; + public readonly requireCredential = ChatRoomChannel.requireCredential; + public readonly kind = ChatRoomChannel.kind; + + constructor( + private chatService: ChatService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ChatRoomChannel { + return new ChatRoomChannel( + this.chatService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts new file mode 100644 index 0000000000..5323484ed7 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import { ChatService } from '@/core/ChatService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ChatUserChannel extends Channel { + public readonly chName = 'chatUser'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private otherId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.otherId !== 'string') return; + this.otherId = params.otherId; + + this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chatUser']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherId) { + this.chatService.readUserChatMessage(this.user!.id, this.otherId); + } + break; + } + } + + @bindThis + public dispose() { + this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); + } +} + +@Injectable() +export class ChatUserChannelService implements MiChannelService { + public readonly shouldShare = ChatUserChannel.shouldShare; + public readonly requireCredential = ChatUserChannel.requireCredential; + public readonly kind = ChatUserChannel.kind; + + constructor( + private chatService: ChatService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ChatUserChannel { + return new ChatUserChannel( + this.chatService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index ed56fe0d40..795980821b 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -50,6 +50,9 @@ class GlobalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; + if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 491029f5de..2984e18774 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -53,6 +53,9 @@ class LocalTimelineChannel extends Channel { if (note.user.host !== null) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; + if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; // 関係ない返信は除外 if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 927970e2e2..30a911088e 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -534,7 +534,7 @@ export class ClientServerService { return await reply.view('user', { user, profile, me, - avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), + avatarUrl: _user.avatarUrl, sub: request.params.sub, ...await this.generateCommonPugData(this.meta), clientCtx: htmlSafeJsonStringify({ diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 9d810ddc84..eae7645321 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -65,7 +65,7 @@ export class FeedService { generator: 'Misskey', description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, - image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), + image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user), feedLinks: { json: `${author.link}.json`, atom: `${author.link}.atom`, diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index b55d327f86..24794cbf2a 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -127,11 +127,6 @@ document.documentElement.classList.add('useSystemFont'); } - const wallpaper = localStorage.getItem('wallpaper'); - if (wallpaper) { - document.documentElement.style.backgroundImage = `url(${wallpaper})`; - } - const customCss = localStorage.getItem('customCss'); if (customCss && customCss.length > 0) { const style = document.createElement('style'); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index c6b1035554..5d5f1e3b71 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -15,6 +15,7 @@ * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された + * chatRoomInvitationReceived - チャットルームに招待された * achievementEarned - 実績を獲得 * exportCompleted - エクスポートが完了 * login - ログイン @@ -34,6 +35,7 @@ export const notificationTypes = [ 'receiveFollowRequest', 'followRequestAccepted', 'roleAssigned', + 'chatRoomInvitationReceived', 'achievementEarned', 'exportCompleted', 'login', @@ -122,6 +124,7 @@ export const moderationLogTypes = [ 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'deleteChatRoom', 'updateProxyAccountDescription', ] as const; @@ -375,6 +378,10 @@ export type ModerationLogPayloads = { postUserUsername: string; post: any; }; + deleteChatRoom: { + roomId: string; + room: any; + }; updateProxyAccountDescription: { before: string | null; after: string | null; diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts index 2250bf4a42..00635e654b 100644 --- a/packages/backend/test-federation/test/timeline.test.ts +++ b/packages/backend/test-federation/test/timeline.test.ts @@ -24,7 +24,7 @@ describe('Timeline', () => { }); type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag'); - type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag'); + type TimelineEndpoint = keyof Misskey.Endpoints & (`notes/${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag'); const timelineMap = new Map([ ['antenna', 'antennas/notes'], ['globalTimeline', 'notes/global-timeline'], diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index 83dcb8df44..ee69e857bc 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -381,7 +381,8 @@ describe('User', () => { await alice.client.request('i/delete-account', { password: alice.password }); // NOTE: user deletion query is slow - await sleep(4000); + // FIXME: ensure user is removed successfully + await sleep(10000); const following = await bob.client.request('users/following', { userId: bob.id }); strictEqual(following.length, 0); // no following relation @@ -480,7 +481,8 @@ describe('User', () => { await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); // NOTE: user deletion query is slow - await sleep(4000); + // FIXME: ensure user is removed successfully + await sleep(10000); const following = await bob.client.request('users/following', { userId: bob.id }); strictEqual(following.length, 0); // no following relation diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index a544db955a..4dbeacf925 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -6,7 +6,6 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, failedApiCall, @@ -19,6 +18,7 @@ import { userList, } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); @@ -146,6 +146,7 @@ describe('アンテナ', () => { caseSensitive: false, createdAt: new Date(response.createdAt).toISOString(), excludeKeywords: [['']], + excludeNotesInSensitiveChannel: false, hasUnreadNote: false, isActive: true, keywords: [['keyword']], @@ -217,6 +218,8 @@ describe('アンテナ', () => { { parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: true }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: false }) }, + { parameters: () => ({ excludeNotesInSensitiveChannel: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ @@ -232,12 +235,12 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' - }) + id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', + }); }); //#endregion //#region 更新(antennas/update) @@ -271,12 +274,12 @@ describe('アンテナ', () => { await failedApiCall({ endpoint: 'antennas/update', parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, - user: alice + user: alice, }, { status: 400, code: 'EMPTY_KEYWORD', - id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' - }) + id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', + }); }); //#endregion @@ -372,14 +375,23 @@ describe('アンテナ', () => { ], }, { - // https://github.com/misskey-dev/misskey/issues/9025 - label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', + label: 'フォロワー限定投稿とDM投稿を含む', parameters: () => ({}), posts: [ { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, - { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, + { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true }, + ], + }, + { + label: 'フォロワー限定投稿とDM投稿を含まない', + parameters: () => ({}), + posts: [ + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'followers' }) }, + { note: (): Promise => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) }, ], }, { @@ -626,6 +638,42 @@ describe('アンテナ', () => { assert.deepStrictEqual(response, expected); }); + test('が取得できること(センシティブチャンネルのノートを除く)', async () => { + const keyword = 'キーワード'; + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[keyword]], excludeNotesInSensitiveChannel: true }, + user: alice, + }); + const nonSensitiveChannel = await successfulApiCall({ + endpoint: 'channels/create', + parameters: { name: 'test', isSensitive: false }, + user: alice, + }); + const sensitiveChannel = await successfulApiCall({ + endpoint: 'channels/create', + parameters: { name: 'test', isSensitive: true }, + user: alice, + }); + + const noteInLocal = await post(bob, { text: `test ${keyword}` }); + const noteInNonSensitiveChannel = await post(bob, { text: `test ${keyword}`, channelId: nonSensitiveChannel.id }); + await post(bob, { text: `test ${keyword}`, channelId: sensitiveChannel.id }); + + const response = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + // 最後に投稿したものが先頭に来る。 + const expected = [ + noteInNonSensitiveChannel, + noteInLocal, + ]; + assert.deepStrictEqual(response, expected); + }); + + test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { }); test.each([ { label: 'ID指定', offsetBy: 'id' }, diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index f37da288b7..b464c24287 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -51,30 +51,8 @@ describe('Mute', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - await post(carol, { text: '@alice hi' }); - - const res = await api('i', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.hasUnreadMentions, false); - }); - - test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); - - assert.strictEqual(fired, false); - }); - test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { // 状態リセット - await api('i/read-all-unread-notes', {}, alice); await api('notifications/mark-all-as-read', {}, alice); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 1ac99df884..1edc178fc2 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -38,48 +38,6 @@ describe('Note thread mute', () => { assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false); }); - test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - const bobNote = await post(bob, { text: '@alice @carol root note' }); - - await api('notes/thread-muting/create', { noteId: bobNote.id }, alice); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - - const res = await api('i', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.hasUnreadMentions, false); - }); - - test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - const bobNote = await post(bob, { text: '@alice @carol root note' }); - - await api('notes/thread-muting/create', { noteId: bobNote.id }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'main', async ({ type, body }) => { - if (type === 'unreadMention') { - if (body === bobNote.id) return; - fired = true; - } - }); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 5000); - })); - test('i/notifications にミュートしているスレッドの通知が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 822ca14ae6..a342bba64c 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -15,7 +15,7 @@ describe('ユーザー', () => { // エンティティとしてのユーザーを主眼においたテストを記述する // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) - const stripUndefined = (orig: T): Partial => { + const stripUndefined = (orig: T): Partial => { return Object.entries({ ...orig }) .filter(([, value]) => value !== undefined) .reduce((obj: Partial, [key, value]) => { @@ -83,6 +83,8 @@ describe('ユーザー', () => { publicReactions: user.publicReactions, followingVisibility: user.followingVisibility, followersVisibility: user.followersVisibility, + chatScope: user.chatScope, + canChat: user.canChat, roles: user.roles, memo: user.memo, }); @@ -132,6 +134,7 @@ describe('ユーザー', () => { hasUnreadAnnouncement: user.hasUnreadAnnouncement, hasUnreadAntenna: user.hasUnreadAntenna, hasUnreadChannel: user.hasUnreadChannel, + hasUnreadChatMessages: user.hasUnreadChatMessages, hasUnreadNotification: user.hasUnreadNotification, unreadNotificationsCount: user.unreadNotificationsCount, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, @@ -343,6 +346,8 @@ describe('ユーザー', () => { assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.followingVisibility, 'public'); assert.strictEqual(response.followersVisibility, 'public'); + assert.strictEqual(response.chatScope, 'mutual'); + assert.strictEqual(response.canChat, true); assert.deepStrictEqual(response.roles, []); assert.strictEqual(response.memo, null); @@ -369,6 +374,7 @@ describe('ユーザー', () => { assert.strictEqual(response.hasUnreadAnnouncement, false); assert.strictEqual(response.hasUnreadAntenna, false); assert.strictEqual(response.hasUnreadChannel, false); + assert.strictEqual(response.hasUnreadChatMessages, false); assert.strictEqual(response.hasUnreadNotification, false); assert.strictEqual(response.unreadNotificationsCount, 0); assert.strictEqual(response.hasPendingReceivedFollowRequest, false); @@ -728,7 +734,7 @@ describe('ユーザー', () => { }); test.each([ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: () => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: () => userMutedByAlice }, + { label: 'ミュートユーザーが含まれない', user: () => userMutedByAlice, excluded: true }, { label: 'ブロックされているユーザーが含まれる', user: () => userBlockedByAlice }, { label: 'ブロックしてきているユーザーが含まれる', user: () => userBlockingAlice }, { label: '承認制ユーザーが含まれる', user: () => userLocking }, diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 81da0fac31..a79655c9aa 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -44,7 +44,7 @@ describe('AnnouncementService', () => { return usersRepository.insert({ id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), ...data, }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index bae2b88c60..0687ed8437 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -89,8 +89,8 @@ describe('SigninWithPasskeyApiService', () => { app = await Test.createTestingModule({ imports: [GlobalModule, CoreModule], providers: [ - SigninWithPasskeyApiService, - { provide: RateLimiterService, useClass: FakeLimiter }, + SigninWithPasskeyApiService, + { provide: RateLimiterService, useClass: FakeLimiter }, { provide: SigninService, useClass: FakeSigninService }, ], }).useMocker((token) => { @@ -115,7 +115,7 @@ describe('SigninWithPasskeyApiService', () => { jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); const dummyUser = { - id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, + id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null, }; const dummyProfile = { userId: uid, diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts index 66a7f39ff1..697425beb8 100644 --- a/packages/backend/test/unit/UserSearchService.ts +++ b/packages/backend/test/unit/UserSearchService.ts @@ -134,13 +134,13 @@ describe('UserSearchService', () => { await app.close(); }); - describe('search', () => { + describe('searchByUsernameAndHost', () => { test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alycia, alysha, alyson]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -154,7 +154,7 @@ describe('UserSearchService', () => { await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -168,7 +168,7 @@ describe('UserSearchService', () => { await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -181,7 +181,7 @@ describe('UserSearchService', () => { test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -195,7 +195,7 @@ describe('UserSearchService', () => { await setActive([root, alyssa, bob, bobbi, alyce, alycia]); await setInactive([alyson, alice, alysha, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { }, { limit: 100 }, root, @@ -216,7 +216,7 @@ describe('UserSearchService', () => { await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, ); @@ -228,7 +228,7 @@ describe('UserSearchService', () => { test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, ); @@ -240,7 +240,7 @@ describe('UserSearchService', () => { await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al', host: 'exam' }, { limit: 100 }, root, @@ -253,7 +253,7 @@ describe('UserSearchService', () => { await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setSuspended([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts index 45bc932469..736aac40b4 100644 --- a/packages/backend/test/unit/WebhookTestService.ts +++ b/packages/backend/test/unit/WebhookTestService.ts @@ -14,6 +14,7 @@ import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersReposi import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; describe('WebhookTestService', () => { let app: TestingModule; @@ -56,6 +57,11 @@ describe('WebhookTestService', () => { providers: [ WebhookTestService, IdService, + { + provide: CustomEmojiService, useFactory: () => ({ + populateEmojis: jest.fn(), + }), + }, { provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn(), diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index e4f42809f8..ce3f931bb0 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -50,6 +50,7 @@ import { AccountMoveService } from '@/core/AccountMoveService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { ChatService } from '@/core/ChatService.js'; process.env.NODE_ENV = 'test'; @@ -73,7 +74,7 @@ describe('UserEntityService', () => { ...userData, id: genAidx(Date.now()), username: un, - usernameLower: un, + usernameLower: un.toLowerCase(), }) .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); @@ -172,6 +173,7 @@ describe('UserEntityService', () => { ReactionService, ReactionsBufferingService, NotificationService, + ChatService, ]; app = await Test.createTestingModule({ diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 21247e32ab..c253a653ee 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -16,55 +16,55 @@ "@rollup/pluginutils": "5.1.4", "@tabler/icons-webfont": "3.31.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.2.1", + "@vitejs/plugin-vue": "5.2.3", "@vue/compiler-sfc": "3.5.13", "astring": "1.9.0", "buraha": "0.0.1", "estree-walker": "3.0.3", + "frontend-shared": "workspace:*", + "json5": "2.2.3", "mfm-js": "0.24.0", "misskey-js": "workspace:*", - "frontend-shared": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.34.9", - "sass": "1.85.1", - "shiki": "3.1.0", + "rollup": "4.39.0", + "sass": "1.86.3", + "shiki": "3.2.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.11", + "tsc-alias": "1.8.15", "tsconfig-paths": "4.2.0", - "typescript": "5.8.2", + "typescript": "5.8.3", "uuid": "11.1.0", - "json5": "2.2.3", - "vite": "6.2.1", + "vite": "6.3.1", "vue": "3.5.13" }, "devDependencies": { "@misskey-dev/summaly": "5.2.0", "@testing-library/vue": "8.1.0", - "@types/estree": "1.0.6", + "@types/estree": "1.0.7", "@types/micromatch": "4.0.9", - "@types/node": "22.13.9", + "@types/node": "22.14.0", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", - "@types/ws": "8.18.0", - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "@vitest/coverage-v8": "3.0.8", + "@types/ws": "8.18.1", + "@typescript-eslint/eslint-plugin": "8.29.1", + "@typescript-eslint/parser": "8.29.1", + "@vitest/coverage-v8": "3.1.1", "@vue/runtime-core": "3.5.13", "acorn": "8.14.1", "cross-env": "7.0.3", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "10.0.0", "fast-glob": "3.3.3", - "happy-dom": "17.3.0", + "happy-dom": "17.4.4", "intersection-observer": "0.12.2", "micromatch": "4.0.8", "msw": "2.7.3", "nodemon": "3.1.9", "prettier": "3.5.3", - "start-server-and-test": "2.0.10", + "start-server-and-test": "2.0.11", "vite-plugin-turbosnap": "1.0.3", "vue-component-type-helpers": "2.2.8", - "vue-eslint-parser": "10.1.1", + "vue-eslint-parser": "10.1.3", "vue-tsc": "2.2.8" } } diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue index bf976c71ae..0bff048ce4 100644 --- a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -33,13 +33,11 @@ const canvasPromise = new Promise(resol Math.min(navigator.hardwareConcurrency - 1, 4), ); resolve(workers); - if (_DEV_) console.log('WebGL2 in worker is supported!'); } else { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); - if (_DEV_) console.log('WebGL2 in worker is not supported...'); } testWorker.terminate(); }); diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue index d711020a74..2c96ce3215 100644 --- a/packages/frontend-embed/src/components/EmMediaImage.vue +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -95,7 +95,7 @@ async function onclick(ev: MouseEvent) { position: absolute; border-radius: 6px; background-color: var(--MI_THEME-fg); - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -153,7 +153,7 @@ html[data-color-scheme=light] .visible { /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--MI_THEME-accentLighten); + color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue index 5d5317a912..94a91305f4 100644 --- a/packages/frontend-embed/src/components/EmPagination.vue +++ b/packages/frontend-embed/src/components/EmPagination.vue @@ -34,10 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only - @@ -420,7 +465,7 @@ onBeforeUnmount(() => { } &:active { - background: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index c81edc2a73..891af7f696 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only [props.instanceUrl, props.sitekey, props.secretKey], async () => { if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { - (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { + (window.document.getElementById(scriptId.value) ?? window.document.head.appendChild(Object.assign(window.document.createElement('script'), { async: true, id: scriptId.value, src: src.value, @@ -149,7 +149,7 @@ async function requestRender() { if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) { // reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する. // (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので) - const elem = document.createElement('div'); + const elem = window.document.createElement('div'); captchaEl.value.appendChild(elem); captchaWidgetId.value = captcha.value.render(elem, { @@ -174,7 +174,7 @@ async function requestRender() { function clearWidget() { if (props.provider === 'mcaptcha') { - const container = document.getElementById('mcaptcha__widget-container'); + const container = window.document.getElementById('mcaptcha__widget-container'); if (container) { container.innerHTML = ''; } diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts index a42e80c27a..4304c2e2b7 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -2,20 +2,18 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { channel } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkChannelFollowButton from './MkChannelFollowButton.vue'; +import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 2ef2d84198..1aec8d0c07 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -103,13 +103,13 @@ async function onClick() { background: var(--MI_THEME-accent); &:hover { - background: var(--MI_THEME-accentLighten); - border-color: var(--MI_THEME-accentLighten); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &:active { - background: var(--MI_THEME-accentDarken); - border-color: var(--MI_THEME-accentDarken); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); + border-color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } } diff --git a/packages/frontend/src/components/MkChatHistories.stories.impl.ts b/packages/frontend/src/components/MkChatHistories.stories.impl.ts new file mode 100644 index 0000000000..8268adc36f --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.stories.impl.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { http, HttpResponse } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { chatMessage } from '../../.storybook/fakes'; +import MkChatHistories from './MkChatHistories.vue'; +import type { StoryObj } from '@storybook/vue3'; +import type * as Misskey from 'misskey-js'; +export const Default = { + render(args) { + return { + components: { + MkChatHistories, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + http.post('/api/chat/history', async ({ request }) => { + const body = await request.json() as Misskey.entities.ChatHistoryRequest; + action('POST /api/chat/history')(body); + return HttpResponse.json([chatMessage(body.room)]); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue new file mode 100644 index 0000000000..c508ea8451 --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.vue @@ -0,0 +1,208 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkClickerGame.stories.impl.ts b/packages/frontend/src/components/MkClickerGame.stories.impl.ts index eb7e61f294..6e1eb13d61 100644 --- a/packages/frontend/src/components/MkClickerGame.stories.impl.ts +++ b/packages/frontend/src/components/MkClickerGame.stories.impl.ts @@ -2,18 +2,16 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-default-export */ -import type { StoryObj } from '@storybook/vue3'; + import { HttpResponse, http } from 'msw'; import { action } from '@storybook/addon-actions'; import { expect, userEvent, within } from '@storybook/test'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkClickerGame from './MkClickerGame.vue'; +import type { StoryObj } from '@storybook/vue3'; function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => window.setTimeout(resolve, ms)); } export const Default = { diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index d2d9f320ee..f41cb0d00b 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only - -

{{ code }}
+ +
{{ code }}
-
diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue new file mode 100644 index 0000000000..99f8df0780 --- /dev/null +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -0,0 +1,56 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index fbdb7d261e..78ac6900a3 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/frontend/src/components/global/SearchIcon.vue b/packages/frontend/src/components/global/SearchIcon.vue new file mode 100644 index 0000000000..27a284faf0 --- /dev/null +++ b/packages/frontend/src/components/global/SearchIcon.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/SearchMarker.vue b/packages/frontend/src/components/global/SearchMarker.vue index 66a78cb7fd..ded1f9a28b 100644 --- a/packages/frontend/src/components/global/SearchMarker.vue +++ b/packages/frontend/src/components/global/SearchMarker.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -21,7 +21,7 @@ import { useTemplateRef, inject, } from 'vue'; -import type { Ref } from 'vue'; +import { DI } from '@/di.js'; const props = defineProps<{ markerId?: string; @@ -36,13 +36,14 @@ const rootEl = useTemplateRef('root'); const rootElMutationObserver = new MutationObserver(() => { checkChildren(); }); -const injectedSearchMarkerId = inject | null>('inAppSearchMarkerId', null); +const injectedSearchMarkerId = inject(DI.inAppSearchMarkerId, null); const searchMarkerId = computed(() => injectedSearchMarkerId?.value ?? window.location.hash.slice(1)); const highlighted = ref(props.markerId === searchMarkerId.value); +const isParentOfTarget = computed(() => props.children?.includes(searchMarkerId.value)); function checkChildren() { - if (props.children?.includes(searchMarkerId.value)) { - const el = document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); + if (isParentOfTarget.value) { + const el = window.document.querySelector(`[data-in-app-search-marker-id="${searchMarkerId.value}"]`); highlighted.value = el == null; } } @@ -105,8 +106,8 @@ onBeforeUnmount(dispose); @keyframes blink { 0%, 100% { - background: color(from var(--MI_THEME-accent) srgb r g b / 0.05); - border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.7); + background: color(from var(--MI_THEME-accent) srgb r g b / 0.1); + border: 1px solid color(from var(--MI_THEME-accent) srgb r g b / 0.75); } 50% { background: transparent; diff --git a/packages/frontend/src/components/grid/MkDataCell.vue b/packages/frontend/src/components/grid/MkDataCell.vue index 7c8a5d64d7..55de0df690 100644 --- a/packages/frontend/src/components/grid/MkDataCell.vue +++ b/packages/frontend/src/components/grid/MkDataCell.vue @@ -345,7 +345,7 @@ $cellHeight: 28px; border: solid 0.5px transparent; &.selected { - border: solid 0.5px var(--MI_THEME-accentLighten); + border: solid 0.5px hsl(from var(--MI_THEME-accent) h s calc(l + 10)); } &.ranged { diff --git a/packages/frontend/src/components/grid/MkGrid.vue b/packages/frontend/src/components/grid/MkGrid.vue index 94f4f3dab1..c37f3df0d3 100644 --- a/packages/frontend/src/components/grid/MkGrid.vue +++ b/packages/frontend/src/components/grid/MkGrid.vue @@ -50,6 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index a56a24ff7d..10925fa4ab 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -296,7 +296,7 @@ onMounted(async () => { left: 0; padding: 12px; border-top: solid 0.5px var(--MI_THEME-divider); - background: var(--MI_THEME-acrylicBg); + background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue index ee87fae606..a569ab7c33 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -4,11 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 7f6424225b..065a8021f1 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -4,63 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/job-queue.job.vue b/packages/frontend/src/pages/admin/job-queue.job.vue new file mode 100644 index 0000000000..71efab0272 --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.job.vue @@ -0,0 +1,280 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue new file mode 100644 index 0000000000..528c473c4f --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -0,0 +1,370 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 3507758b6e..983dd1b2ee 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -4,131 +4,127 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue index 45f0fff107..b53667e98c 100644 --- a/packages/frontend/src/pages/admin/system-webhook.item.vue +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -75,6 +75,6 @@ function onDeleteClick() { margin-right: 0.75em; flex-shrink: 0; text-align: center; - color: var(--MI_THEME-fgTransparentWeak); + color: color(from var(--MI_THEME-fg) srgb r g b / 0.75); } diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index d8eb9b92ee..a3214028e6 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -4,11 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue new file mode 100644 index 0000000000..b063a0cdd1 --- /dev/null +++ b/packages/frontend/src/pages/chat/XRoom.vue @@ -0,0 +1,41 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue new file mode 100644 index 0000000000..a0853fb0c9 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -0,0 +1,131 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue new file mode 100644 index 0000000000..82b22ea9dd --- /dev/null +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -0,0 +1,93 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue new file mode 100644 index 0000000000..f9fd6bfd55 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -0,0 +1,45 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue new file mode 100644 index 0000000000..ce7da15563 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue new file mode 100644 index 0000000000..1edd18ddf0 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.vue @@ -0,0 +1,60 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue new file mode 100644 index 0000000000..3ac90a93fd --- /dev/null +++ b/packages/frontend/src/pages/chat/message.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue new file mode 100644 index 0000000000..9389b16ce7 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -0,0 +1,350 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..2f091388a0 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,99 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..5a574068cb --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,98 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..20b6e22a46 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..8b351c1ec8 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,517 @@ + + + + + + + diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index fcc1d1d5db..479204f39b 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -4,12 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index 38850c29f7..86398b731e 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index 9b2b40374e..39055268d4 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -8,10 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only
- + +
+ @@ -45,23 +47,77 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ i18n.ts.setWallpaper }} + {{ i18n.ts.removeWallpaper }} + +
diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 2fbc9ab4b3..e0cd58439e 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.timeline }} -