diff --git a/.editorconfig b/.editorconfig index def7baa1a8..ccf388f06e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,7 @@ trim_trailing_whitespace = false [*.{yml,yaml}] indent_style = space + +[packages/backend/migration/*.js] +indent_style = space +indent_size = 4 diff --git a/.github/min.node-version b/.github/min.node-version index d5a159609d..d4b7699d36 100644 --- a/.github/min.node-version +++ b/.github/min.node-version @@ -1 +1 @@ -20.10.0 +20.18.1 diff --git a/.github/misskey/test.yml b/.github/misskey/test.yml index 3c807e8b9e..513bfb1ac0 100644 --- a/.github/misskey/test.yml +++ b/.github/misskey/test.yml @@ -15,3 +15,5 @@ redis: host: 127.0.0.1 port: 56312 id: aidx + +proxyRemoteFiles: true diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 9d611c9964..e4b139ef63 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -18,6 +18,14 @@ on: - packages/misskey-js/** - .github/workflows/test-backend.yml - .github/misskey/test.yml + workflow_dispatch: + inputs: + force_ffmpeg_cache_update: + description: 'Force update ffmpeg cache' + required: false + default: false + type: boolean + jobs: unit: name: Unit tests (backend) @@ -47,7 +55,22 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 + - name: Get current date + id: current-date + run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + - name: Setup and Restore ffmpeg/ffprobe Cache + id: cache-ffmpeg + uses: actions/cache@v4 + with: + path: | + /usr/local/bin/ffmpeg + /usr/local/bin/ffprobe + # daily cache + key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }} + restore-keys: | + ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }} - name: Install FFmpeg + if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true run: | for i in {1..3}; do echo "Attempt $i: Installing FFmpeg..." @@ -129,3 +152,47 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/backend/coverage/coverage-final.json + + migration: + name: Migration tests (backend) + runs-on: ubuntu-latest + strategy: + matrix: + node-version-file: + - .node-version + #- .github/min.node-version + + services: + postgres: + image: postgres:15 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + + steps: + - uses: actions/checkout@v4.2.2 + with: + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + - name: Get current date + id: current-date + run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + - name: Use Node.js + uses: actions/setup-node@v4.4.0 + with: + node-version-file: ${{ matrix.node-version-file }} + cache: 'pnpm' + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: pnpm build + - name: Run migrations + run: MISSKEY_CONFIG_YML=test.yml pnpm --filter backend migrate + - name: Check no migrations are remaining + run: MISSKEY_CONFIG_YML=test.yml pnpm --filter backend check-migrations diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml index 737b543a73..873396f622 100644 --- a/.github/workflows/test-federation.yml +++ b/.github/workflows/test-federation.yml @@ -14,6 +14,13 @@ on: - packages/backend/** - packages/misskey-js/** - .github/workflows/test-federation.yml + workflow_dispatch: + inputs: + force_ffmpeg_cache_update: + description: 'Force update ffmpeg cache' + required: false + default: false + type: boolean jobs: test: @@ -30,7 +37,22 @@ jobs: submodules: true - name: Setup pnpm uses: pnpm/action-setup@v4.1.0 + - name: Get current date + id: current-date + run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + - name: Setup and Restore ffmpeg/ffprobe Cache + id: cache-ffmpeg + uses: actions/cache@v4 + with: + path: | + /usr/local/bin/ffmpeg + /usr/local/bin/ffprobe + # daily cache + key: ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }} + restore-keys: | + ${{ runner.os }}-ffmpeg-${{ steps.current-date.outputs.today }} - name: Install FFmpeg + if: steps.cache-ffmpeg.outputs.cache-hit != 'true' || github.event.inputs.force_ffmpeg_cache_update == true run: | for i in {1..3}; do echo "Attempt $i: Installing FFmpeg..." diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ceec23acd..5f36a32af4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,8 +6,12 @@ "files.associations": { "*.test.ts": "typescript" }, - "jest.jestCommandLine": "pnpm run jest", "jest.runMode": "on-demand", + "jest.virtualFolders": [ + { "name": "backend unit", "jestCommandLine": "pnpm -F backend run test" }, + { "name": "backend e2e", "jestCommandLine": "pnpm -F backend run test:e2e"}, + { "name": "misskey-js", "jestCommandLine": "pnpm -F misskey-js run jest" } + ], "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e335b5f0..2a31031830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## 2025.7.0 + +### Note +- Node.jsの最小バージョンを20.10.0から20.18.1に引き上げました + - なお、特に必要がない限りNode.jsは推奨バージョンであるv22を使用するようにしてください + +### General +- Feat: ノートの下書き機能 +- Feat: クリップ内でノートを検索できるように +- Feat: Playを検索できるように +- Feat: モデレーションにおいて、特定のドライブファイルを添付しているチャットメッセージを一覧できるように +- Enhance: ウォーターマーク機能をロールで制御可能に + +### Client +- Note: 「自動でもっと見る」オプションは無効になっています +- Feat: モデログを検索できるように +- Enhance: 設定の自動バックアップをオンにした直後に自動バックアップするように +- Enhance: ファイルアップロード前にキャプション設定を行えるように +- Enhance: ファイルアップロード時にセンシティブ設定されているか表示するように +- Enhance: 投稿フォームにファイルをペースト/ドロップした際のUXを改善 +- Enhance: ページネーション(一覧表示)の並び順を逆にできるように +- Enhance: ページネーション(一覧表示)の基準日時を指定できるように +- Enhance: レンダリングパフォーマンスの向上 +- Fix: ファイルがドライブの既定アップロード先に指定したフォルダにアップロードされない問題を修正 +- Fix: プラグインをアンインストールしてもセーブデータが残る問題を修正 +- Fix: 数時間後Misskeyのタブに戻った際に、タブがスロットリングされている間の更新アニメーションを延々見せ続けられる問題を修正 +- Fix: 非ログイン時のハイライトノートの画像がCWの有無を考慮せず表示される問題を修正 +- Fix: レンジ選択・ドロップダウンにて、操作を無効にすべきところで無効にならない問題を修正 +- Fix: Pull to refreshが有効なときに横スクロールができない問題を修正 + +### Server +- Enhance: sinceId/untilIdが指定可能なエンドポイントにおいて、sinceDate/untilDateも指定可能に +- Enhance: メールの送信者としてサーバー名を表示するように (サーバー名が設定されている場合) +- Fix: ジョブキューのProgressの値を正しく計算する + + ## 2025.6.3 ### Client diff --git a/Dockerfile b/Dockerfile index 77277db8cb..62b737c084 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ WORKDIR /misskey COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] COPY --link ["scripts", "./scripts"] +COPY --link ["patches", "./patches"] COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] @@ -53,6 +54,7 @@ WORKDIR /misskey COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] COPY --link ["scripts", "./scripts"] +COPY --link ["patches", "./patches"] COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index 2ce39737ed..4f2f700146 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -78,6 +78,8 @@ describe('After setup instance', () => { cy.get('[data-cy-signup-password] input').type('alice1234'); cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); + cy.get('[data-cy-signup-invitation-code] input').type('test-invitation-code'); cy.get('[data-cy-signup-submit]').should('not.be.disabled'); cy.get('[data-cy-signup-submit]').click(); diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 33abcf3488..6e5882f087 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -1313,6 +1313,7 @@ availableRoles: "Roles disponibles " acknowledgeNotesAndEnable: "Activa'l després de comprendre els possibles perills." federationSpecified: "Aquest servidor treballa amb una federació de llistes blanques. No pot interactuar amb altres servidors que no siguin els especificats per l'administrador." federationDisabled: "La unió es troba deshabilitada en aquest servidor. No es pot interactuar amb usuaris d'altres servidors." +draft: "Esborrany " confirmOnReact: "Confirmar en reaccionar" reactAreYouSure: "Vols reaccionar amb \"{emoji}\"?" markAsSensitiveConfirm: "Vols marcar aquest contingut com a sensible?" @@ -1350,7 +1351,7 @@ 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" -federationJobs: "Treballs sindicats " +federationJobs: "Treballs de federació" driveAboutTip: "Al Disc veure's una llista de tots els arxius que has anat pujant.
\nPots tornar-los a fer servir adjuntant-los a notes noves o pots adelantar-te i pujar arxius per publicar-los més tard!
\nTingués en compte que si esborres un arxiu també desapareixerà de tots els llocs on l'has fet servir (notes, pàgines, avatars, imatges de capçalera, etc.)
\nTambé pots crear carpetes per organitzar les." scrollToClose: "Desplaçar per tancar" advice: "Consell" @@ -1367,6 +1368,9 @@ redisplayAllTips: "Torna ha mostrat tots els trucs i consells" hideAllTips: "Amagar tots els trucs i consells" defaultImageCompressionLevel: "Nivell de comprensió de la imatge per defecte" defaultImageCompressionLevel_description: "Baixa, conserva la qualitat de la imatge però la mida de l'arxiu és més gran.
Alta, redueix la mida de l'arxiu però també la qualitat de la imatge." +_order: + newest: "Més recent" + oldest: "Cronològic" _chat: noMessagesYet: "Encara no tens missatges " newMessage: "Missatge nou" @@ -1993,6 +1997,8 @@ _role: uploadableFileTypes: "Tipus de fitxers que en podeu pujar" uploadableFileTypes_caption: "Especifica el tipus MIME. Es poden especificar diferents tipus MIME separats amb una nova línia, i es poden especificar comodins amb asteriscs (*). (Per exemple: image/*)" uploadableFileTypes_caption2: "Pot que no sigui possible determinar el tipus MIME d'alguns arxius. Per permetre aquests tipus d'arxius afegeix {x} a les especificacions." + noteDraftLimit: "Nombre possible d'esborranys de notes al servidor" + watermarkAvailable: "Pots fer servir la marca d'aigua" _condition: roleAssignedTo: "Assignat a rols manuals" isLocal: "Usuari local" @@ -2152,6 +2158,7 @@ _theme: install: "Instal·lar un tema" manage: "Gestionar els temes " code: "Codi del tema" + copyThemeCode: "Copiar el codi del tema" description: "Descripció" installed: "{name} Instal·lat " installedThemes: "Temes instal·lats " @@ -2800,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "Pujat el" attachedNotes: "Notes amb aquest fitxer" + usage: "Ús " thisPageCanBeSeenFromTheAuthor: "Aquesta pàgina només la pot veure l'usuari que ha pujat aquest fitxer." _externalResourceInstaller: title: "Instal·lar des d'un lloc extern" @@ -3103,6 +3111,7 @@ _serverSetupWizard: text2: "Si ho desitges, agrairíem molt la teva donació per poder seguir desenvolupant el projecte." text3: "També hi ha privilegis especials per als donants!" _uploader: + editImage: "Edició d'imatges" compressedToX: "Comprimit a {x}" savedXPercent: "{x}% d'estalvi " abortConfirm: "Hi ha un arxiu que no s'ha pujat, vols cancel·lar?" @@ -3171,3 +3180,18 @@ _imageEffector: checker: "Escacs" blockNoise: "Bloqueig de soroll" tearing: "Trencament d'imatge " +drafts: "Esborrany " +_drafts: + select: "Seleccionar esborrany" + cannotCreateDraftAnymore: "S'ha sobrepassat el nombre màxim d'esborranys que es poden crear." + cannotCreateDraft: "Amb aquest contingut no es poden crear esborranys." + delete: "Esborrar esborranys" + deleteAreYouSure: "Vols esborrar els esborranys?" + noDrafts: "No hi ha esborranys" + replyTo: "Respondre a {user}" + quoteOf: "Citar les notes de {user}" + postTo: "Destinat a {channel}" + saveToDraft: "Desar com a esborrany" + restoreFromDraft: "Restaurar des dels esborranys" + restore: "Restaurar esborrany" + listDrafts: "Llistat d'esborranys" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 189085e805..202e512e63 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1313,6 +1313,7 @@ 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." +draft: "Entwurf" confirmOnReact: "Reagieren bestätigen" reactAreYouSure: "Willst du eine \"{emoji}\"-Reaktion hinzufügen?" markAsSensitiveConfirm: "Möchtest du dieses Medium als sensibel kennzeichnen?" @@ -1347,7 +1348,7 @@ 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)" +settingsMigrating: "Deine Einstellungen werden gerade migriert. Bitte warte einen Moment... (Du kannst die Einstellungen später auch manuell migrieren, indem du zu Einstellungen → Anderes → Alte Einstellungen migrieren gehst)" readonly: "Nur Lesezugriff" goToDeck: "Zurück zum Deck" federationJobs: "Föderation Jobs" @@ -1367,6 +1368,9 @@ redisplayAllTips: "Alle „Tipps und Tricks“ wieder anzeigen" hideAllTips: "Alle „Tipps und Tricks“ ausblenden" defaultImageCompressionLevel: "Standard-Bildkomprimierungsstufe" defaultImageCompressionLevel_description: "Ein niedrigerer Wert erhält die Bildqualität, erhöht aber die Dateigröße.
Höhere Werte reduzieren die Dateigröße, verringern aber die Bildqualität." +_order: + newest: "Neueste zuerst" + oldest: "Älteste zuerst" _chat: noMessagesYet: "Noch keine Nachrichten" newMessage: "Neue Nachricht" @@ -1645,6 +1649,7 @@ _serverSettings: allowExternalApRedirect_description: "Wenn diese Option aktiviert ist, können andere Server Inhalte von Drittanbietern über diesen Server abfragen, was jedoch zu Content-Spoofing führen kann." userGeneratedContentsVisibilityForVisitor: "Sichtbarkeit von nutzergenerierten Inhalten für Gäste" userGeneratedContentsVisibilityForVisitor_description: "Dies ist nützlich, um zu verhindern, dass unangemessene Inhalte, die nicht gut moderiert sind, ungewollt über deinen eigenen Server im Internet veröffentlicht werden." + userGeneratedContentsVisibilityForVisitor_description2: "Die uneingeschränkte Veröffentlichung aller Inhalte des Servers im Internet, einschließlich der vom Server empfangenen Fremdinhalte, birgt Risiken. Dies ist besonders wichtig für Betrachter, die sich des dezentralen Charakters der Inhalte nicht bewusst sind, da sie selbst fremde Inhalte fälschlicherweise als auf dem Server erstellte Inhalte wahrnehmen könnten." _userGeneratedContentsVisibilityForVisitor: all: "Alles ist öffentlich" localOnly: "Nur lokale Inhalte werden veröffentlicht, fremde Inhalte bleiben privat" @@ -1992,6 +1997,8 @@ _role: uploadableFileTypes: "Hochladbare Dateitypen" uploadableFileTypes_caption: "Gibt die zulässigen MIME-/Dateitypen an. Mehrere MIME-Typen können durch einen Zeilenumbruch getrennt angegeben werden, und Platzhalter können mit einem Sternchen (*) angegeben werden. (z. B. image/*)" uploadableFileTypes_caption2: "Bei manchen Dateien ist es nicht möglich, den Typ zu bestimmen. Um solche Dateien zuzulassen, füge {x} der Spezifikation hinzu." + noteDraftLimit: "Anzahl der möglichen Entwürfe für serverseitige Notizen" + watermarkAvailable: "Kann die Wasserzeichenfunktion verwenden" _condition: roleAssignedTo: "Manuellen Rollen zugewiesen" isLocal: "Lokaler Benutzer" @@ -2151,6 +2158,7 @@ _theme: install: "Farbschemata installieren" manage: "Farbschemaverwaltung" code: "Farbschemencode" + copyThemeCode: "Farbschemencode kopieren" description: "Beschreibung" installed: "{name} wurde installiert" installedThemes: "Installierte Farbschemata" @@ -2464,6 +2472,8 @@ _visibility: disableFederation: "Deföderieren" disableFederationDescription: "Nicht an andere Instanzen übertragen" _postForm: + quitInspiteOfThereAreUnuploadedFilesConfirm: "Es gibt Dateien, die nicht hochgeladen wurden. Möchtest du diese verwerfen und das Formular schließen?" + uploaderTip: "Die Datei wurde noch nicht hochgeladen. Über das Dateimenü kannst du sie umbenennen, das Bild zuschneiden, ein Wasserzeichen hinzufügen, komprimieren usw. Die Datei wird automatisch hochgeladen, wenn du eine Notiz veröffentlichst." replyPlaceholder: "Dieser Notiz antworten …" quotePlaceholder: "Diese Notiz zitieren …" channelPlaceholder: "In einen Kanal senden" @@ -2844,8 +2854,12 @@ _dataSaver: _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." + _urlPreviewThumbnail: + title: "URL-Vorschaubilder ausblenden" + description: "URL-Vorschaubilder werden nicht mehr geladen." _disableUrlPreview: title: "URL-Vorschau deaktivieren" + description: "Deaktiviert die URL-Vorschaufunktion. Anders als bei reinen Vorschaubildern wird dadurch das Laden der verlinkten Informationen selbst reduziert." _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." @@ -2903,6 +2917,8 @@ _offlineScreen: _urlPreviewSetting: title: "Einstellungen der URL-Vorschau" enable: "URL-Vorschau aktivieren" + allowRedirect: "Umleitung von URL-Vorschauen erlauben" + allowRedirectDescription: "Wenn für eine URL eine Umleitung festgelegt ist, kann diese Funktion aktiviert werden, um der Umleitung zu folgen und eine Vorschau des umgeleiteten Inhalts anzuzeigen. Die Deaktivierung spart Serverressourcen, aber der Inhalt des Weiterleitungsziels wird nicht angezeigt." timeout: "Zeitüberschreitung beim Abrufen der Vorschau (ms)" timeoutDescription: "Übersteigt die für die Vorschau benötigte Zeit diesen Wert, wird keine Vorschau generiert." maximumContentLength: "Maximale Content-Length (Bytes)" @@ -3064,8 +3080,11 @@ _serverSetupWizard: single_description: "Verwende den Server alleine als deinen eigenen." single_youCanCreateMultipleAccounts: "Bei Bedarf können mehrere Konten eingerichtet werden, auch wenn es sich um einen Ein-Personen-Server handelt." group: "Gruppenserver" + group_description: "Lade andere vertrauenswürdige Benutzer ein und verwende es mit mehreren Personen." open: "Offener Server" open_description: "Registrierung für alle öffnen." + openServerAdvice: "Die Aufnahme einer unbestimmten Anzahl von Nutzern birgt Risiken. Es wird empfohlen, mit einem zuverlässigen Moderationssystem zu arbeiten, um eventuell auftretende Probleme behandeln zu können." + openServerAntiSpamAdvice: "Große Sorgfalt muss auch auf die Sicherheit gelegt werden, z. B. durch die Aktivierung von Anti-Bot-Funktionen wie reCAPTCHA, um sicherzustellen, dass der Server nicht zum Verbreiten von Spam genutzt wird." howManyUsersDoYouExpect: "Mit wie vielen Benutzern rechnest du?" _scale: small: "Weniger als 100 (kleiner Maßstab)" @@ -3076,6 +3095,8 @@ _serverSetupWizard: doYouConnectToFediverse_description1: "Bei Anschluss an ein Netz von verteilten Servern (Fediverse) können Inhalte mit anderen Servern ausgetauscht werden." doYouConnectToFediverse_description2: "Die Verbindung mit dem Fediverse wird auch als „Föderation“ bezeichnet." youCanConfigureMoreFederationSettingsLater: "Erweiterte Einstellungen, wie z. B. die Angabe von föderierbaren Servern, können später vorgenommen werden." + adminInfo: "Administrator-Informationen" + adminInfo_description: "Legt die Administrator-Informationen fest, die für den Empfang von Anfragen verwendet werden." adminInfo_mustBeFilled: "Dies ist auf einem offenen Server oder bei aktivierter Föderation erforderlich." followingSettingsAreRecommended: "Die folgenden Einstellungen werden empfohlen" applyTheseSettings: "Diese Einstellungen anwenden" @@ -3089,6 +3110,7 @@ _serverSetupWizard: text2: "Wir würden uns über deine Unterstützung freuen, damit wir dieses Projekt auch in Zukunft weiterentwickeln können." text3: "Für Unterstützer gibt es auch besondere Vorteile!" _uploader: + editImage: "Bild bearbeiten" compressedToX: "Komprimiert zu {x}" savedXPercent: "{x}% gespart" abortConfirm: "Einige Dateien wurden nicht hochgeladen. Möchtest du den Vorgang abbrechen?" @@ -3110,10 +3132,12 @@ _userLists: watermark: "Wasserzeichen" defaultPreset: "Standard-Voreinstellungen" _watermarkEditor: + tip: "Dem Bild kann ein Wasserzeichen, z. B. eine Quellenangabe, hinzugefügt werden." quitWithoutSaveConfirm: "Nicht gespeicherte Änderungen verwerfen?" driveFileTypeWarn: "Diese Datei wird nicht unterstützt" driveFileTypeWarnDescription: "Bilddatei auswählen" title: "Wasserzeichen bearbeiten" + cover: "Alles bedecken" opacity: "Transparenz" scale: "Größe" text: "Text" @@ -3121,7 +3145,16 @@ _watermarkEditor: type: "Art" image: "Bilder" advanced: "Fortgeschritten" + stripe: "Streifen" + stripeWidth: "Linienbreite" + stripeFrequency: "Linienanzahl" angle: "Winkel" + polkadot: "Punktmuster" + polkadotMainDotOpacity: "Deckkraft des Hauptpunktes" + polkadotMainDotRadius: "Größe des Hauptpunktes" + polkadotSubDotOpacity: "Deckkraft des Unterpunktes" + polkadotSubDotRadius: "Größe des Unterpunktes" + polkadotSubDotDivisions: "Anzahl der Unterpunkte" _imageEffector: title: "Effekte" addEffect: "Effekte hinzufügen" @@ -3133,3 +3166,22 @@ _imageEffector: invert: "Farben umkehren" grayscale: "Schwarzweiß" colorAdjust: "Farbkorrektur" + colorClamp: "Farbkomprimierung" + colorClampAdvanced: "Farbkomprimierung (erweitert)" + distort: "Verzerrung" + stripe: "Streifen" + polkadot: "Punktmuster" +drafts: "Entwurf" +_drafts: + select: "Entwurf auswählen" + cannotCreateDraftAnymore: "Die Anzahl der Entwürfe, die erstellt werden können, wurde überschritten." + cannotCreateDraft: "Mit diesem Inhalt kann kein Entwurf erstellt werden." + delete: "Entwurf löschen" + deleteAreYouSure: "Entwurf löschen?" + noDrafts: "Keine Entwürfe" + replyTo: "Antwort an {user}" + quoteOf: "Zitat von {user}s Notiz" + saveToDraft: "Als Entwurf speichern" + restoreFromDraft: "Aus Entwurf wiederherstellen" + restore: "Wiederherstellen" + listDrafts: "Liste der Entwürfe" diff --git a/locales/en-US.yml b/locales/en-US.yml index 8d877af899..cc3b8f23ba 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -327,7 +327,7 @@ dark: "Dark" lightThemes: "Light themes" darkThemes: "Dark themes" syncDeviceDarkMode: "Sync Dark Mode with your device settings" -switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" is turned on, Would you like to turn off synchronization and switch modes manually?" +switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" is turned on. Would you like to turn off synchronization and switch modes manually?" drive: "Drive" fileName: "Filename" selectFile: "Select a file" @@ -1313,6 +1313,7 @@ availableRoles: "Available roles" acknowledgeNotesAndEnable: "Turn on after understanding the precautions." federationSpecified: "This server is operated in a whitelist federation. Interacting with servers other than those designated by the administrator is not allowed." federationDisabled: "Federation is disabled on this server. You cannot interact with users on other servers." +draft: "Drafts" confirmOnReact: "Confirm when reacting" reactAreYouSure: "Would you like to add a \"{emoji}\" reaction?" markAsSensitiveConfirm: "Do you want to set this media as sensitive?" @@ -1366,7 +1367,10 @@ tip: "Tips & Tricks" redisplayAllTips: "Show all “Tips & Tricks” again" hideAllTips: "Hide all \"Tips & Tricks\"" defaultImageCompressionLevel: "Default image compression level" -defaultImageCompressionLevel_description: "High, reduces the file size but also the image quality.
High, reduces the file size but also the image quality." +defaultImageCompressionLevel_description: "Lower level preserves image quality but increases file size.
Higher level reduce file size, but reduce image quality." +_order: + newest: "Newest First" + oldest: "Oldest First" _chat: noMessagesYet: "No messages yet" newMessage: "New message" @@ -1993,6 +1997,8 @@ _role: uploadableFileTypes: "Uploadable file types" uploadableFileTypes_caption: "Specifies the allowed MIME/file types. Multiple MIME types can be specified by separating them with a new line, and wildcards can be specified with an asterisk (*). (e.g., image/*)" uploadableFileTypes_caption2: "Some files types might fail to be detected. To allow such files, add {x} to the specification." + noteDraftLimit: "Number of possible drafts of server notes" + watermarkAvailable: "Availability of watermark function" _condition: roleAssignedTo: "Assigned to manual roles" isLocal: "Local user" @@ -2152,6 +2158,7 @@ _theme: install: "Install a theme" manage: "Manage themes" code: "Theme code" + copyThemeCode: "Copy theme code" description: "Description" installed: "{name} has been installed" installedThemes: "Installed themes" @@ -2800,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "Uploaded at" attachedNotes: "Attached notes" + usage: "Used" thisPageCanBeSeenFromTheAuthor: "This page can only be seen by the user who uploaded this file." _externalResourceInstaller: title: "Install from external site" @@ -3103,6 +3111,7 @@ _serverSetupWizard: text2: "We would appreciate your support so that we can continue to develop this software further into the future." text3: "There are also special benefits for supporters!" _uploader: + editImage: "Edit Image" compressedToX: "Compressed to {x}" savedXPercent: "Saving {x}%" abortConfirm: "Some files have not been uploaded, do you want to abort?" @@ -3159,8 +3168,8 @@ _imageEffector: glitch: "Glitch" mirror: "Mirror" invert: "Invert Colors" - grayscale: "white-black" - colorAdjust: "Colour Correction" + grayscale: "Grayscale" + colorAdjust: "Color Correction" colorClamp: "Color Compression" colorClampAdvanced: "Color Compression (Advanced)" distort: "Distortion" @@ -3171,3 +3180,18 @@ _imageEffector: checker: "Checker" blockNoise: "Block Noise" tearing: "Tearing" +drafts: "Drafts" +_drafts: + select: "Select Draft" + cannotCreateDraftAnymore: "The number of drafts that can be created has been exceeded." + cannotCreateDraft: "You cannot create a draft with this content." + delete: "Delete Draft" + deleteAreYouSure: "Delete draft?" + noDrafts: "No drafts" + replyTo: "Reply to {user}" + quoteOf: "Citation to {user}'s note" + postTo: "Posting to {channel}" + saveToDraft: "Save to Draft" + restoreFromDraft: "Restore from Draft" + restore: "Restore" + listDrafts: "List of Drafts" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 4b3eef3a0d..9afebc6580 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -61,11 +61,11 @@ copyRSS: "Copiar RSS" copyUsername: "Copiar nombre de usuario" copyUserId: "Copiar ID del usuario" copyNoteId: "Copiar ID de la nota" -copyFileId: "Copiar ID del archivo" +copyFileId: "Copiar ID de archivo" copyFolderId: "Copiar ID de carpeta" copyProfileUrl: "Copiar la URL del perfil" searchUser: "Buscar un usuario" -searchThisUsersNotes: "" +searchThisUsersNotes: "Buscar en las notas de este usuario" reply: "Responder" loadMore: "Ver más" showMore: "Ver más" @@ -83,10 +83,10 @@ files: "Archivos" download: "Descargar" driveFileDeleteConfirm: "¿Desea borrar el archivo \"{name}\"? Las notas que tengan este archivo como adjunto serán eliminadas" unfollowConfirm: "¿Desea dejar de seguir a {name}?" -exportRequested: "Se ha solicitado la exportación. Puede tomar un tiempo. Cuando termine la exportación, se añadirá en el drive" -importRequested: "Se ha solicitado la importación. Puede tomar un tiempo." +exportRequested: "Has solicitado la exportación. Puede llevar un tiempo. Cuando termine la exportación, se añadirá al drive" +importRequested: "Has solicitado la importación. Puede llevar un tiempo." lists: "Listas" -noLists: "No tiene listas" +noLists: "No tienes ninguna lista" note: "Notas" notes: "Notas" following: "Siguiendo" @@ -99,9 +99,9 @@ somethingHappened: "Ocurrió un error" retry: "Reintentar" pageLoadError: "Error al leer la página" pageLoadErrorDescription: "Normalmente es debido a la red o al caché del navegador. Por favor limpie el caché o intente más tarde." -serverIsDead: "No hay respuesta del servidor. Espere un momento y vuelva a intentarlo." -youShouldUpgradeClient: "Para ver esta página, por favor refrezca el navegador y utiliza una versión más reciente del cliente." -enterListName: "Ingrese nombre de lista" +serverIsDead: "No hay respuesta del servidor. Espera un momento y vuelve a intentarlo." +youShouldUpgradeClient: "Para ver esta página, recarga el navegador para actualizar el cliente." +enterListName: "Introduce un nombre para la lista" privacy: "Privacidad" makeFollowManuallyApprove: "Aprobar manualmente las solicitudes de seguimiento" defaultNoteVisibility: "Visibilidad por defecto" @@ -125,7 +125,7 @@ renoteToOtherChannel: "Renotar a otro canal" pinnedNote: "Nota fijada" pinned: "Fijar al perfil" you: "Tú" -clickToShow: "Click para ver" +clickToShow: "Haz clic para verlo" sensitive: "Marcado como sensible" add: "Agregar" reaction: "Reacción" @@ -134,28 +134,28 @@ emojiPicker: "Selector de emojis" pinnedEmojisForReactionSettingDescription: "Puedes seleccionar reacciones para fijarlos en el selector" pinnedEmojisSettingDescription: "Puedes seleccionar emojis para fijarlos en el selector" emojiPickerDisplay: "Mostrar el selector de emojis" -overwriteFromPinnedEmojisForReaction: "Sobreescribir las reacciones fijadas" -overwriteFromPinnedEmojis: "Sobreescribir los emojis fijados" -reactionSettingDescription2: "Arrastre para reordenar, click para borrar, apriete la tecla + para añadir." +overwriteFromPinnedEmojisForReaction: "Sobreescribir los ajustes de reacciones" +overwriteFromPinnedEmojis: "Sobreescribir los ajustes generales" +reactionSettingDescription2: "Arrastra para reordenar, click para borrar, pulsa \"+\" para añadir." rememberNoteVisibility: "Recordar visibilidad" attachCancel: "Quitar adjunto" -deleteFile: "Archivo eliminado" +deleteFile: "Eliminar archivo" markAsSensitive: "Marcar como sensible" unmarkAsSensitive: "Desmarcar como sensible" -enterFileName: "Ingrese el nombre del archivo" +enterFileName: "Introduce el nombre del archivo" mute: "Silenciar" unmute: "Dejar de silenciar" renoteMute: "Silenciar renota" renoteUnmute: "Desilenciar renota" block: "Bloquear" -unblock: "Dejar de bloquear" +unblock: "Desbloquear" suspend: "Suspender" unsuspend: "Dejar de suspender" -blockConfirm: "¿Quiere bloquear esta cuenta?" -unblockConfirm: "¿Quiere dejar de bloquear esta cuenta?" -suspendConfirm: "¿Quiere suspender esta cuenta?" -unsuspendConfirm: "¿Quiere dejar de suspender esta cuenta?" -selectList: "Seleccione una lista" +blockConfirm: "¿Quieres bloquear esta cuenta?" +unblockConfirm: "¿Quieres desbloquear esta cuenta?" +suspendConfirm: "¿Quieres suspender esta cuenta?" +unsuspendConfirm: "¿Quieres dejar de suspender esta cuenta?" +selectList: "Selecciona una lista" editList: "Editar lista" selectChannel: "Seleccionar canal" selectAntenna: "Seleccionar antena" @@ -163,55 +163,55 @@ editAntenna: "Editar antena" createAntenna: "Crear una antena" selectWidget: "Seleccionar widget" editWidgets: "Editar widgets" -editWidgetsExit: "Terminar edición" +editWidgetsExit: "Hecho" customEmojis: "Emojis personalizados" emoji: "Emoji" -emojis: "Emoji" +emojis: "Emojis" emojiName: "Nombre del emoji" -emojiUrl: "URL de la imágen del emoji" -addEmoji: "Agregar emoji" -settingGuide: "Configuración sugerida" -cacheRemoteFiles: "Mantener en cache los archivos remotos" -cacheRemoteFilesDescription: "Si desactiva esta configuración, Los archivos remotos se cargarán desde el link directo sin usar la caché. Con eso se puede ahorrar almacenamiento del servidor, pero eso aumentará el tráfico al no crear miniaturas." +emojiUrl: "URL del emoji" +addEmoji: "Añadir emoji" +settingGuide: "Configuración recomendada" +cacheRemoteFiles: "Mantener los archivos remotos en caché" +cacheRemoteFilesDescription: "Si desactivas esta configuración, los archivos remotos se cargarán directamente de los servidores remotos. Desactivar esto reducirá el uso de almacenamiento, pero incrementará el uso de tráfico, ya que no se generarán miniaturas." youCanCleanRemoteFilesCache: "Puedes vaciar la caché pulsando en el botón 🗑️ en el administrador de archivos." -cacheRemoteSensitiveFiles: "Cachear archivos remotos sensibles" -cacheRemoteSensitiveFilesDescription: "Cuando esta opción está desactivada, los archivos remotos sensibles son cargador directamente de la instancia origen sin ser cacheados." +cacheRemoteSensitiveFiles: "Mantener los archivos remotos sensibles en caché" +cacheRemoteSensitiveFilesDescription: "Cuando esta opción está desactivada, los archivos remotos sensibles se cargarán directamente desde los servidores remotos." flagAsBot: "Esta cuenta es un bot" -flagAsBotDescription: "En caso de que esta cuenta fuera usada por un programa, active esta opción. Al hacerlo, esta opción servirá para otros desarrolladores para evitar cadenas infinitas de reacciones, y ajustará los sistemas internos de Misskey para que trate a esta cuenta como un bot." -flagAsCat: "Esta cuenta es un gato" -flagAsCatDescription: "En caso de que declare que esta cuenta es de un gato, active esta opción." -flagShowTimelineReplies: "Mostrar respuestas a las notas en la biografía" -flagShowTimelineRepliesDescription: "Cuando se marca, la línea de tiempo muestra respuestas a otras notas además de las notas del usuario" +flagAsBotDescription: "Activa esta opción si la cuenta es utilizada por un programa. Si se activa, actuará como una etiqueta para otros desarrolladores para prevenir cadenas eternas de interacción con otros bots, y ajustará los sistemas internos de Misskey para tratar esta cuenta de manera acorde." +flagAsCat: "Marcar esta cuenta como gato" +flagAsCatDescription: "Activa esta opción para marcar esta cuenta como un gato." +flagShowTimelineReplies: "Mostrar respuestas en la línea de tiempo" +flagShowTimelineRepliesDescription: "Muestra respuestas de los usuarios a las notas de otros usuarios en la línea de tiempo al activar esta opción." autoAcceptFollowed: "Aceptar automáticamente las solicitudes de seguimiento de los usuarios que sigues" -addAccount: "Agregar Cuenta" +addAccount: "Agregar cuenta" reloadAccountsList: "Recargar lista de cuentas" loginFailed: "Error al iniciar sesión." -showOnRemote: "Ver en una instancia remota" -continueOnRemote: "Ver en una instancia remota" +showOnRemote: "Ver en instancia remota" +continueOnRemote: "Continuar en una instancia remota" chooseServerOnMisskeyHub: "Elegir un servidor en Misskey Hub" specifyServerHost: "Especifica una instancia directamente" -inputHostName: "Introduzca el dominio" +inputHostName: "Introduce el dominio" general: "General" wallpaper: "Fondo de pantalla" setWallpaper: "Establecer fondo de pantalla" removeWallpaper: "Quitar fondo de pantalla" searchWith: "Buscar: {q}" -youHaveNoLists: "No tienes listas" -followConfirm: "¿Desea seguir a {name}?" +youHaveNoLists: "No tienes ninguna lista" +followConfirm: "¿Quieres seguir a {name}?" proxyAccount: "Cuenta proxy" -proxyAccountDescription: "Una cuenta proxy es una cuenta que actúa como un seguidor remoto de un usuario bajo ciertas condiciones. Por ejemplo, cuando un usuario añade un usuario remoto a una lista, si ningún usuario local sigue al usuario agregado a la lista, la instancia no puede obtener su actividad. Así que la cuenta proxy sigue al usuario añadido a la lista" -host: "Host" +proxyAccountDescription: "Una cuenta proxy es una cuenta que actúa como un seguidor remoto de un usuario bajo ciertas condiciones. Por ejemplo, cuando un usuario añade un usuario remoto a una lista, si ningún usuario local sigue al usuario agregado a la lista, la instancia no puede obtener su actividad, así que la cuenta proxy sigue al usuario añadido a la lista" +host: "Instancia" selectSelf: "Elígete a ti mismo" selectUser: "Elegir usuario" -recipient: "Recipiente" +recipient: "Receptor" annotation: "Anotación" federation: "Federación" -instances: "Instancia" +instances: "Instancias" registeredAt: "Registrado en" -latestRequestReceivedAt: "Ultimo pedido recibido" -latestStatus: "Último status" +latestRequestReceivedAt: "Última petición recibida" +latestStatus: "Último estado" storageUsage: "Almacenamiento usado" -charts: "Chat" +charts: "Métricas" perHour: "por hora" perDay: "por día" stopActivityDelivery: "Dejar de enviar actividades" @@ -226,40 +226,40 @@ metadata: "Metadatos" withNFiles: "{n} archivos" monitor: "Monitor" jobQueue: "Cola de trabajos" -cpuAndMemory: "CPU y Memoria" +cpuAndMemory: "CPU y memoria" network: "Red" disk: "Disco" -instanceInfo: "información de la instancia" +instanceInfo: "Información de la instancia" statistics: "Estadísticas" clearQueue: "Limpiar cola" -clearQueueConfirmTitle: "¿Desea limpiar la cola?" +clearQueueConfirmTitle: "¿Quieres limpiar la cola?" clearQueueConfirmText: "Las notas aún no entregadas no se federarán. Normalmente no se necesita ejecutar esta operación" clearCachedFiles: "Limpiar caché" -clearCachedFilesConfirm: "¿Desea borrar todos los archivos remotos cacheados?" +clearCachedFilesConfirm: "¿Quieres borrar todos los archivos remotos en caché?" blockedInstances: "Instancias bloqueadas" -blockedInstancesDescription: "Seleccione los hosts de las instancias que desea bloquear, separadas por una linea nueva. Las instancias bloqueadas no podrán comunicarse con esta instancia." +blockedInstancesDescription: "La lista de los dominios de las instancias que quieres bloquear, separadas por una linea nueva. Las instancias bloqueadas no podrán comunicarse con esta instancia." silencedInstances: "Instancias silenciadas" -silencedInstancesDescription: "Listar los hostname de las instancias que quieres silenciar. Todas las cuentas de las instancias listadas serán tratadas como silenciadas, solo podrán hacer peticiones de seguimiento, y no podrán mencionar cuentas locales si no las siguen. Esto no afecta a las instancias bloqueadas." -mediaSilencedInstances: "Servidores silenciados (Multimedia)" -mediaSilencedInstancesDescription: "Listar las instancias que quieres silenciar. Todas las cuentas de las instancias listadas serán tratadas como silenciadas, solo podrán hacer peticiones de seguimiento, y no podrán mencionar cuentas locales si no las siguen. Esto no afecta a las instancias bloqueadas." +silencedInstancesDescription: "La lista de los dominios de las instancias que quieres silenciar. Todas las cuentas de las instancias listadas serán tratadas como silenciadas, solo podrán hacer peticiones de seguimiento, y no podrán mencionar cuentas locales si no las siguen. Esto no afecta a las instancias bloqueadas." +mediaSilencedInstances: "Servidores con multimedia silenciada" +mediaSilencedInstancesDescription: "La lista de los dominios de las instancias cuya multimedia quieres silenciar. Todas las cuentas que pertenezcan a estas instancias serán marcadas como sensibles, y no podrán usar sus emojis personalizados. Esto no afectará a las instancias bloqueadas" federationAllowedHosts: "Servidores federados" -federationAllowedHostsDescription: "Establezca los nombres de los servidores que pueden federarse, separados por una nueva línea." +federationAllowedHostsDescription: "La lista de los dominios de las instancias cuya federación está permitida, separadas por saltos de línea." muteAndBlock: "Silenciar y bloquear" mutedUsers: "Usuarios silenciados" blockedUsers: "Usuarios bloqueados" noUsers: "No hay usuarios" editProfile: "Editar perfil" -noteDeleteConfirm: "¿Desea borrar esta nota?" -pinLimitExceeded: "Ya no se pueden fijar más posts" +noteDeleteConfirm: "¿Quieres borrar esta nota?" +pinLimitExceeded: "Ya no se pueden fijar más notas" done: "Terminado" -processing: "Procesando" +processing: "Procesando..." preview: "Vista previa" default: "Predeterminado" defaultValueIs: "Por defecto: {value}" noCustomEmojis: "No hay emojis personalizados" noJobs: "No hay trabajos" federating: "Federando" -blocked: "Bloqueando" +blocked: "Bloqueado" suspended: "Suspendido" all: "Todo" subscribing: "Suscribiendo" @@ -848,7 +848,7 @@ info: "Información" userInfo: "Información del usuario" unknown: "Desconocido" onlineStatus: "En línea" -hideOnlineStatus: "mostrarse como desconectado" +hideOnlineStatus: "Mostrarse como desconectado" hideOnlineStatusDescription: "Ocultar su estado en línea puede reducir la eficacia de algunas funciones, como la búsqueda" online: "En línea" active: "Activo" @@ -1313,6 +1313,7 @@ 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" +draft: "Borrador" confirmOnReact: "Confirmar la reacción" reactAreYouSure: "¿Quieres añadir una reacción «{emoji}»?" markAsSensitiveConfirm: "¿Desea establecer este medio multimedia(Imagen,vídeo...) como sensible?" @@ -1367,6 +1368,9 @@ redisplayAllTips: "Volver a mostrar todos \"Trucos y consejos\"" hideAllTips: "Ocultar todos los \"Trucos y consejos\"" defaultImageCompressionLevel: "Nivel de compresión de la imagen por defecto" defaultImageCompressionLevel_description: "Baja, conserva la calidad de la imagen pero la medida del archivo es más grande.
Alta, reduce la medida del archivo pero también la calidad de la imagen." +_order: + newest: "Los más recientes primero" + oldest: "Los más antiguos primero" _chat: noMessagesYet: "Aún no hay mensajes" newMessage: "Mensajes nuevos" @@ -1382,7 +1386,7 @@ _chat: noInvitations: "No hay invitación." history: "Historial" noHistory: "No hay datos en el historial" - noRooms: "Sala no encontrada" + noRooms: "No te has unido a ninguna sala " inviteUser: "Invitar usuarios" sentInvitations: "Invitaciones enviadas" join: "Unirse" @@ -1483,7 +1487,7 @@ _accountSettings: makeNotesHiddenBeforeDescription: "Mientras esta función esté activada, las notas que hayan pasado la fecha y hora fijadas o hayan transcurrido el tiempo establecido sólo serán visibles para ti (se harán privadas). Si la desactivas, también se restablecerá el estado público de las notas." mayNotEffectForFederatedNotes: "Notas federadas por un servidor remoto pueden no verse afectadas." mayNotEffectSomeSituations: "Estas restricciones son simplificadas. Pueden no aplicarse en algunas situaciones, como cuando se visualiza en un servidor remoto o durante la moderación." - notesHavePassedSpecifiedPeriod: "Ten en cuenta que el tiempo especificado ha pasado" + notesHavePassedSpecifiedPeriod: "Notas publicadas durante el siguiente tiempo específico" notesOlderThanSpecifiedDateAndTime: "Notas antes de la fecha y hora especificadas" _abuseUserReport: forward: "Reenviar" @@ -1993,6 +1997,8 @@ _role: uploadableFileTypes: "Tipos de archivos que se pueden cargar." uploadableFileTypes_caption: "Especifica los tipos MIME/archivos permitidos. Se pueden especificar varios tipos MIME separándolos con una nueva línea, y se pueden especificar comodines con un asterisco (*). (por ejemplo, image/*)" uploadableFileTypes_caption2: "Es posible que no se detecten algunos tipos de archivos. Para permitir estos archivos, añade {x} a la especificación." + noteDraftLimit: "Número de posibles borradores de notas del servidor" + watermarkAvailable: "Disponibilidad de la función de marca de agua" _condition: roleAssignedTo: "Asignado a roles manuales" isLocal: "Usuario local" @@ -2152,6 +2158,7 @@ _theme: install: "Instalar tema" manage: "Gestor de temas" code: "Código del tema" + copyThemeCode: "Copiar el código del tema" description: "Descripción" installed: "{name} ha sido instalado" installedThemes: "Temas instalados" @@ -2262,7 +2269,7 @@ _2fa: setupCompleted: "Configuración completada" step4: "Ahora cuando inicie sesión, ingrese el mismo token" securityKeyNotSupported: "Tu navegador no soporta claves de autenticación." - registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key.\npor favor. configura una aplicación de autenticación para registrar una llave de seguridad." + registerTOTPBeforeKey: "Por favor. configura una aplicación de autenticación para registrar una llave de seguridad." securityKeyInfo: "Se puede configurar el inicio de sesión usando una clave de seguridad de hardware que soporte FIDO2 o con un certificado de huella digital o con un PIN" registerSecurityKey: "Registrar una llave de seguridad" securityKeyName: "Ingresa un nombre para la clave" @@ -2800,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "Subido el" attachedNotes: "Notas adjuntas" + usage: "Utilizado" thisPageCanBeSeenFromTheAuthor: "Esta página solo puede ser vista por el autor." _externalResourceInstaller: title: "Instalar desde sitio externo" @@ -2902,7 +2910,7 @@ _reversi: opponentHasSettingsChanged: "El oponente ha cambiado su configuración" allowIrregularRules: "Reglas irregulares (completamente libre)" disallowIrregularRules: "Sin reglas irregulares " - showBoardLabels: "Mostrar el número de línea y de columna en el tablero de juego." + showBoardLabels: "Mostrar el número de línea y la letra de columna en el tablero de juego." useAvatarAsStone: "Usar los avatares de los usuarios como fichas\n" _offlineScreen: title: "Fuera de línea. No se puede conectar con el servidor" @@ -3103,6 +3111,7 @@ _serverSetupWizard: text2: "Agradeceríamos su apoyo para que podamos seguir desarrollando este software en el futuro." text3: "También hay beneficios especiales para los donantes" _uploader: + editImage: "Editar la imagen" compressedToX: "Comprimir a {x}" savedXPercent: "Guardando {x}%" abortConfirm: "Algunos archivos no se han cargado, ¿deseas cancelar?" @@ -3171,3 +3180,18 @@ _imageEffector: checker: "Corrector" blockNoise: "Bloquear Ruido" tearing: "Rasgado de Imagen (Tearing)" +drafts: "Borrador" +_drafts: + select: "Seleccionar borradores" + cannotCreateDraftAnymore: "Se ha superado el número de borradores que se pueden crear." + cannotCreateDraft: "No se pueden crear borradores con este contenido." + delete: "Eliminar borrador" + deleteAreYouSure: "¿Quieres borrar el borrador?" + noDrafts: "No hay borradores disponibles." + replyTo: "Responder a {user}" + quoteOf: "Citar las notas de {user}" + postTo: "Destino a {channel}" + saveToDraft: "Guardar como borrador" + restoreFromDraft: "Restaurar desde los borradores" + restore: "Restaurar" + listDrafts: "Listar los borradores" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 0e8e46dc7b..fb8713d3b3 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -5,9 +5,13 @@ introMisskey: "Selamat datang! Misskey adalah perangkat mikroblog tercatu bersif poweredByMisskeyDescription: "{name} adalah sebuah layanan (instance) yang menggunakan platform sumber terbuka Misskey." monthAndDay: "{day} {month}" search: "Penelusuran" +reset: "Reset" notifications: "Notifikasi" username: "Nama Pengguna" password: "Kata sandi" +initialPasswordForSetup: "Kata sandi untuk memulai konfigurasi awal" +initialPasswordIsIncorrect: "Kata sandi untuk memulai konfigurasi awal salah." +initialPasswordForSetupDescription: "Jika Anda menginstal Misskey sendiri, gunakan kata sandi yang Anda masukkan di berkas konfigurasi.\nJika Anda menggunakan layanan hosting Misskey, gunakan kata sandi yang diberikan.\nJika Anda belum mengatur kata sandi, biarkan kosong dan lanjutkan." forgotPassword: "Lupa Kata Sandi" fetchingAsApObject: "Mengambil data dari Fediverse..." ok: "OK" @@ -45,6 +49,7 @@ pin: "Sematkan ke profil" unpin: "Lepas sematan dari profil" copyContent: "Salin konten" copyLink: "Salin tautan" +copyRemoteLink: "Salin tautan jarak jauh" copyLinkRenote: "Salin tautan renote" delete: "Hapus" deleteAndEdit: "Hapus dan sunting" @@ -212,8 +217,10 @@ perDay: "per Hari" stopActivityDelivery: "Berhenti mengirim aktivitas" blockThisInstance: "Blokir instansi ini" silenceThisInstance: "Senyapkan instansi ini" +mediaSilenceThisInstance: "Server media senyap" operations: "Tindakan" software: "Perangkat lunak" +softwareName: "Nama Perangkat Lunak" version: "Versi" metadata: "Metadata" withNFiles: "{n} berkas" @@ -1040,7 +1047,7 @@ disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi disableFederationOk: "Matikan federasi" invitationRequiredToRegister: "Instansi ini dalam mode undangan-saja. Kamu harus memasukkan kode undangan yang valid untuk mendaftar." emailNotSupported: "Instansi ini tidak mendukung mengirim surel" -postToTheChannel: "Catat ke kanal" +postToTheChannel: "Buat Catatan ke Kanal" cannotBeChangedLater: "Hal ini nantinya tidak dapat diubah lagi." reactionAcceptance: "Penerimaan reaksi" likeOnly: "Hanya suka" @@ -2400,7 +2407,7 @@ _deck: main: "Utama" widgets: "Widget" notifications: "Notifikasi" - tl: "Lini masa" + tl: "Beranda" antenna: "Antena" list: "Daftar" channel: "Kanal" diff --git a/locales/index.d.ts b/locales/index.d.ts index 16003570a2..8d757ff579 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5231,7 +5231,7 @@ export interface Locale extends ILocale { */ "prohibitedWordsForNameOfUser": string; /** - * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。 + * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。ユーザー名(username)に対しても全て小文字に置き換えて検査します。 */ "prohibitedWordsForNameOfUserDescription": string; /** @@ -5270,6 +5270,10 @@ export interface Locale extends ILocale { * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。 */ "federationDisabled": string; + /** + * 下書き + */ + "draft": string; /** * リアクションする際に確認する */ @@ -5489,6 +5493,16 @@ export interface Locale extends ILocale { * 低くすると画質を保てますが、ファイルサイズは増加します。
高くするとファイルサイズを減らせますが、画質は低下します。 */ "defaultImageCompressionLevel_description": string; + "_order": { + /** + * 新しい順 + */ + "newest": string; + /** + * 古い順 + */ + "oldest": string; + }; "_chat": { /** * まだメッセージはありません @@ -7777,6 +7791,14 @@ export interface Locale extends ILocale { * ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。 */ "uploadableFileTypes_caption2": ParameterizedString<"x">; + /** + * サーバーサイドのノートの下書きの作成可能数 + */ + "noteDraftLimit": string; + /** + * ウォーターマーク機能の使用可否 + */ + "watermarkAvailable": string; }; "_condition": { /** @@ -8366,6 +8388,10 @@ export interface Locale extends ILocale { * テーマコード */ "code": string; + /** + * テーマコードをコピー + */ + "copyThemeCode": string; /** * 説明 */ @@ -10868,6 +10894,10 @@ export interface Locale extends ILocale { * 添付されているノート */ "attachedNotes": string; + /** + * 利用 + */ + "usage": string; /** * このページは、このファイルをアップロードしたユーザーしか閲覧できません。 */ @@ -11969,6 +11999,10 @@ export interface Locale extends ILocale { }; }; "_uploader": { + /** + * 画像の編集 + */ + "editImage": string; /** * {x}に圧縮 */ @@ -12230,6 +12264,64 @@ export interface Locale extends ILocale { "tearing": string; }; }; + /** + * 下書き + */ + "drafts": string; + "_drafts": { + /** + * 下書きを選択 + */ + "select": string; + /** + * 下書きの作成可能数を超えています。 + */ + "cannotCreateDraftAnymore": string; + /** + * この内容では下書きを作成できません。 + */ + "cannotCreateDraft": string; + /** + * 下書きを削除 + */ + "delete": string; + /** + * 下書きを削除しますか? + */ + "deleteAreYouSure": string; + /** + * 下書きはありません + */ + "noDrafts": string; + /** + * {user}への返信 + */ + "replyTo": ParameterizedString<"user">; + /** + * {user}のノートへの引用 + */ + "quoteOf": ParameterizedString<"user">; + /** + * {channel}への投稿 + */ + "postTo": ParameterizedString<"channel">; + /** + * 下書きへ保存 + */ + "saveToDraft": string; + /** + * 下書きから復元 + */ + "restoreFromDraft": string; + /** + * 復元 + */ + "restore": string; + /** + * 下書き一覧 + */ + "listDrafts": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/it-IT.yml b/locales/it-IT.yml index ba443bdb8a..23d46d6e22 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -3169,3 +3169,5 @@ _imageEffector: stripe: "Strisce" polkadot: "A pallini" checker: "revisore" +_drafts: + restore: "Ripristina" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6ef92e0f2e..161edfe8bb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1303,7 +1303,7 @@ messageToFollower: "フォロワーへのメッセージ" target: "対象" testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)" -prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。" +prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。ユーザー名(username)に対しても全て小文字に置き換えて検査します。" yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています" yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。" thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています" @@ -1313,6 +1313,7 @@ availableRoles: "利用可能なロール" acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。" federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" +draft: "下書き" confirmOnReact: "リアクションする際に確認する" reactAreYouSure: "\" {emoji} \" をリアクションしますか?" markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?" @@ -1368,6 +1369,10 @@ hideAllTips: "全ての「ヒントとコツ」を非表示" defaultImageCompressionLevel: "デフォルトの画像圧縮度" defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。
高くするとファイルサイズを減らせますが、画質は低下します。" +_order: + newest: "新しい順" + oldest: "古い順" + _chat: noMessagesYet: "まだメッセージはありません" newMessage: "新しいメッセージ" @@ -2013,6 +2018,8 @@ _role: uploadableFileTypes: "アップロード可能なファイル種別" uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)" uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。" + noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数" + watermarkAvailable: "ウォーターマーク機能の使用可否" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -2193,6 +2200,7 @@ _theme: install: "テーマのインストール" manage: "テーマの管理" code: "テーマコード" + copyThemeCode: "テーマコードをコピー" description: "説明" installed: "{name}をインストールしました" installedThemes: "インストールされたテーマ" @@ -2878,6 +2886,7 @@ _fileViewer: url: "URL" uploadedAt: "追加日" attachedNotes: "添付されているノート" + usage: "利用" thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。" _externalResourceInstaller: @@ -3201,6 +3210,7 @@ _serverSetupWizard: text3: "支援者向け特典もあります!" _uploader: + editImage: "画像の編集" compressedToX: "{x}に圧縮" savedXPercent: "{x}%節約" abortConfirm: "アップロードされていないファイルがありますが、中止しますか?" @@ -3275,3 +3285,19 @@ _imageEffector: checker: "チェッカー" blockNoise: "ブロックノイズ" tearing: "ティアリング" + +drafts: "下書き" +_drafts: + select: "下書きを選択" + cannotCreateDraftAnymore: "下書きの作成可能数を超えています。" + cannotCreateDraft: "この内容では下書きを作成できません。" + delete: "下書きを削除" + deleteAreYouSure: "下書きを削除しますか?" + noDrafts: "下書きはありません" + replyTo: "{user}への返信" + quoteOf: "{user}のノートへの引用" + postTo: "{channel}への投稿" + saveToDraft: "下書きへ保存" + restoreFromDraft: "下書きから復元" + restore: "復元" + listDrafts: "下書き一覧" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index c5be794433..00a470a718 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1313,6 +1313,7 @@ availableRoles: "사용 가능한 역할" acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했습니다." federationSpecified: "이 서버는 화이트 리스트 제도로 운영 중 입니다. 정해진 리모트 서버가 아닌 경우 연합되지 않습니다." federationDisabled: "이 서버는 연합을 하지 않고 있습니다. 리모트 서버 유저와 통신을 할 수 없습니다." +draft: "초안" confirmOnReact: "리액션할 때 확인" reactAreYouSure: "\" {emoji} \"로 리액션하시겠습니까?" markAsSensitiveConfirm: "이 미디어를 민감한 미디어로 설정하시겠습니까?" @@ -1367,6 +1368,9 @@ redisplayAllTips: "모든 '팁과 유용한 정보'를 재표시" hideAllTips: "모든 '팁과 유용한 정보'를 비표시" defaultImageCompressionLevel: "기본 이미지 압축 정도" defaultImageCompressionLevel_description: "낮추면 화질을 유지합니다만 파일 크기는 증가합니다.
높이면 파일 크기를 줄일 수 있습니다만 화질은 저하됩니다." +_order: + newest: "최신 순" + oldest: "오래된 순" _chat: noMessagesYet: "아직 메시지가 없습니다" newMessage: "새로운 메시지" @@ -1993,6 +1997,8 @@ _role: uploadableFileTypes: "업로드 가능한 파일 유형" uploadableFileTypes_caption: "MIME 유형을 " uploadableFileTypes_caption2: "파일에 따라서는 유형을 검사하지 못하는 경우가 있습니다. 그러한 파일을 허가하는 경우에는 {x}를 지정으로 추가해주십시오." + noteDraftLimit: "서버측 노트 초안 작성 가능 수" + watermarkAvailable: "워터마크 기능의 사용 여부" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" isLocal: "로컬 유저" @@ -2152,6 +2158,7 @@ _theme: install: "테마 설치" manage: "테마 관리" code: "테마 코드" + copyThemeCode: "테마 코드 복사" description: "설명" installed: "{name} 테마가 설치되었습니다" installedThemes: "설치된 테마" @@ -2800,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "업로드 날짜" attachedNotes: "첨부된 노트" + usage: "이용" thisPageCanBeSeenFromTheAuthor: "이 페이지는 파일 소유자만 열람할 수 있습니다" _externalResourceInstaller: title: "외부 사이트로부터 설치" @@ -3103,6 +3111,7 @@ _serverSetupWizard: text2: "앞으로도 계속해서 개발을 할 수 있도록 괜찮으시다면 부디 기부를 부탁드립니다." text3: "지원자 대상 특전도 있습니다!" _uploader: + editImage: "이미지 편집" compressedToX: "{x}로 압축" savedXPercent: "{x}% 절약" abortConfirm: "업로드되지 않은 파일이 있습니다만, 그만 두시겠습니까?" @@ -3171,3 +3180,18 @@ _imageEffector: checker: "체크 무늬" blockNoise: "노이즈 방지" tearing: "티어링" +drafts: "초안" +_drafts: + select: "초안 선택" + cannotCreateDraftAnymore: "초안 작성 가능 수를 초과했습니다." + cannotCreateDraft: "이 내용으로는 초안을 작성할 수 없습니다. " + delete: "초안 삭제\n" + deleteAreYouSure: "초안을 삭제하시겠습니까?" + noDrafts: "초안 없음\n" + replyTo: "{user}에 회신" + quoteOf: "{user} 노트에 인용" + postTo: "{channel}에 게시" + saveToDraft: "초안에 저장" + restoreFromDraft: "초안에서 복원\n" + restore: "복원" + listDrafts: "초안 목록" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 01300db953..978f81b626 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -298,6 +298,7 @@ uploadFromUrl: "Enviar por URL" uploadFromUrlDescription: "URL do arquivo que você deseja enviar" uploadFromUrlRequested: "Upload solicitado" uploadFromUrlMayTakeTime: "Pode levar algum tempo para que o upload seja concluído." +uploadNFiles: "Enviar {n} arquivos" explore: "Explorar" messageRead: "Lida" noMoreHistory: "Não existe histórico anterior" @@ -326,6 +327,7 @@ dark: "Escuro" lightThemes: "Tema claro" darkThemes: "Tema escuro" syncDeviceDarkMode: "Sincronize com o modo escuro do dispositivo" +switchDarkModeManuallyWhenSyncEnabledConfirm: "\"{x}\" está ativado. Você gostaria de desligar a sincronização e alterar manualmente?" drive: "Drive" fileName: "Nome do Ficheiro" selectFile: "Selecione os arquivos" @@ -444,7 +446,7 @@ exploreUsersCount: "Há um utilizador de {count}" exploreFediverse: "Explorar Fediverse" popularTags: "Tags populares" userList: "Listas" -about: "Informações" +about: "Sobre" aboutMisskey: "Sobre Misskey" administrator: "Administrador" token: "Símbolo" @@ -578,6 +580,7 @@ newNoteRecived: "Nova nota recebida" newNote: "Nova Nota" sounds: "Sons" sound: "Sons" +notificationSoundSettings: "Configurações de som de notificações" listen: "Ouvir" none: "Nenhum" showInPage: "Ver na página" @@ -999,6 +1002,7 @@ failedToUpload: "Falha ao enviar" cannotUploadBecauseInappropriate: "Esse arquivo não pôde ser enviado porque partes dele foram detectadas como potencialmente inapropriadas." cannotUploadBecauseNoFreeSpace: "Envio falhou devido à falta de capacidade no Drive." cannotUploadBecauseExceedsFileSizeLimit: "Não é possível realizar o upload deste arquivo porque ele excede o tamanho máximo permitido." +cannotUploadBecauseUnallowedFileType: "Não foi possível fazer o envio, pois o formato do arquivo não foi autorizado." beta: "Beta" enableAutoSensitive: "Marcar automaticamente como conteúdo sensível" enableAutoSensitiveDescription: "Quando disponível, a marcação de mídia sensível será automaticamente atribuído ao conteúdo de mídia usando aprendizado de máquina. Mesmo que você desative essa função, em alguns servidores, isso pode ser configurado automaticamente." @@ -1309,6 +1313,7 @@ availableRoles: "Cargos disponíveis" acknowledgeNotesAndEnable: "Ative após compreender as precauções." federationSpecified: "Esse servidor opera com uma lista branca de federação. Interagir com servidores diferentes daqueles designados pela administração não é permitido." federationDisabled: "Federação está desabilitada nesse servidor. Você não pode interagir com usuários de outros servidores." +draft: "Rascunhos" confirmOnReact: "Confirmar ao reagir" reactAreYouSure: "Você deseja adicionar uma reação \"{emoji}\"?" markAsSensitiveConfirm: "Você deseja definir essa mídia como sensível?" @@ -1326,6 +1331,7 @@ restore: "Redefinir" syncBetweenDevices: "Sincronizar entre dispositivos" preferenceSyncConflictTitle: "O valor configurado já existe no servidor." preferenceSyncConflictText: "As preferências com a sincronização ativada irão salvar os seus valores no servidor. Porém, já existem valores no servidor. Qual conjunto de valores você deseja sobrescrever?" +preferenceSyncConflictChoiceMerge: "Combinar" preferenceSyncConflictChoiceServer: "Valor configurado no servidor" preferenceSyncConflictChoiceDevice: "Valor configurado no dispositivo" preferenceSyncConflictChoiceCancel: "Cancelar a habilitação de sincronização" @@ -1333,7 +1339,7 @@ paste: "Colar" emojiPalette: "Paleta de emojis" postForm: "Campo de postagem" textCount: "Contagem de caracteres" -information: "Informações" +information: "Sobre" chat: "Conversas" migrateOldSettings: "Migrar configurações antigas de cliente" migrateOldSettings_description: "Isso deve ser feito automaticamente. Caso o processo de migração tenha falhado, você pode acioná-lo manualmente. As informações atuais de migração serão substituídas." @@ -1356,6 +1362,15 @@ emojiMute: "Silenciar emoji" emojiUnmute: "Reativar emoji" muteX: "Silenciar {x}" unmuteX: "Reativar {x}" +abort: "Abortar" +tip: "Dicas e Truques" +redisplayAllTips: "Mostrar todas as \"Dicas e Truques\" novamente" +hideAllTips: "Ocultas todas as \"Dicas e Truques\"" +defaultImageCompressionLevel: "Nível de compressão de imagem padrão" +defaultImageCompressionLevel_description: "Alto, reduz o tamanho do arquivo mas, também, a qualidade da imagem.
Alto, reduz o tamanho do arquivo mas, também, a qualidade da imagem." +_order: + newest: "Priorizar Mais Novos" + oldest: "Priorizar Mais Antigos" _chat: noMessagesYet: "Ainda não há mensagens" newMessage: "Nova mensagem" @@ -1442,6 +1457,8 @@ _settings: contentsUpdateFrequency: "Frequência da obtenção de conteúdo" contentsUpdateFrequency_description: "Quanto maior o valor, mais o conteúdo atualiza. Porém, há uma diminuição do desempenho e aumento do tráfego e consumo de memória." contentsUpdateFrequency_description2: "Quando o modo tempo-real está ativado, o conteúdo é atualizado em tempo real, ignorando essa opção." + showUrlPreview: "Exibir prévia de URL" + showAvailableReactionsFirstInNote: "Exibir reações disponíveis no topo." _chat: showSenderName: "Exibir nome de usuário do remetente" sendOnEnter: "Pressionar Enter para enviar" @@ -1977,6 +1994,11 @@ _role: canImportMuting: "Permitir importação de silenciamentos" canImportUserLists: "Permitir importação de listas" chatAvailability: "Permitir Conversas" + uploadableFileTypes: "Tipos de arquivo enviáveis" + uploadableFileTypes_caption: "Especifica tipos MIME permitidos. Múltiplos tipos MIME podem ser especificados separando-os por linha. Curingas podem ser especificados com um asterisco (*). (exemplo, image/*)" + uploadableFileTypes_caption2: "Alguns tipos de arquivos podem não ser detectados. Para permiti-los, adicione {x} à especificação." + noteDraftLimit: "Limite de rascunhos possíveis" + watermarkAvailable: "Disponibilidade da função de marca d'água" _condition: roleAssignedTo: "Atribuído a cargos manuais" isLocal: "Usuário local" @@ -2136,6 +2158,7 @@ _theme: install: "Instalar um tema" manage: "Gerenciar temas" code: "Código do tema" + copyThemeCode: "Copiar código do tema" description: "Descrição" installed: "{name} foi instalado" installedThemes: "Temas instalados" @@ -2449,6 +2472,8 @@ _visibility: disableFederation: "Defederar" disableFederationDescription: "Não transmitir às outras instâncias" _postForm: + quitInspiteOfThereAreUnuploadedFilesConfirm: "Há arquivos que não foram enviados, gostaria de descartá-los e fechar o editor?" + uploaderTip: "O arquivo ainda não foi enviado. No menu do arquivo, você pode renomear, cortar, adicionar uma marca d'água, comprimir ou descomprimir um arquivo. Arquivos serão enviados automaticamente ao publicar a nota." replyPlaceholder: "Responder a essa nota..." quotePlaceholder: "Citar essa nota..." channelPlaceholder: "Postar em canal..." @@ -2782,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "Adicionado em" attachedNotes: "Notas anexadas" + usage: "Usado" thisPageCanBeSeenFromTheAuthor: "Essa página só pode ser vista pelo usuário que enviou esse arquivo." _externalResourceInstaller: title: "Instalar de site externo" @@ -2829,6 +2855,12 @@ _dataSaver: _avatar: title: "Imagem do avatar" description: "Parar animação de avatares. Imagens animadas podem ter um arquivo mais pesado do que imagens normais, potencialmente levando a reduções no tráfego de dados." + _urlPreviewThumbnail: + title: "Esconder miniaturas em prévias de URL" + description: "Miniaturas em prévias de URL não serão carregadas." + _disableUrlPreview: + title: "Desabilitar prévias de URL" + description: "Desabilita a função de prévias de URL. Diferente das miniaturas, essa função impede o carregamento de toda informação do link." _code: title: "Destaque de código" description: "Se as notações de formatação de código forem utilizadas em MFM, elas não irão carregar até serem selecionadas. Destaque de código exige baixar arquivos de alta definição para cada linguagem de programação. Logo, desabilitar o carregamento automático desses arquivos diminui a quantidade de informação comunicada." @@ -2886,6 +2918,8 @@ _offlineScreen: _urlPreviewSetting: title: "Configurações da prévia de URL" enable: "Habilitar prévia de URL" + allowRedirect: "Permitir redirecionamentos de URL em prévias." + allowRedirectDescription: "Se um URL tem um redirecionamento, você pode habilitar essa função para segui-lo e exibir a prévia do conteúdo redirecionado. Desabilitar isso irá economizar recursos, mas o conteúdo não será exibido." timeout: "Tempo máximo para obter a prévia (ms)" timeoutDescription: "Se demorar mais que esse valor para obter uma prévia, ela não será gerada." maximumContentLength: "Content-Length máximo (em bytes)" @@ -3076,6 +3110,15 @@ _serverSetupWizard: text1: "Misskey é software aberto desenvolvido por voluntários." text2: "Nós apreciaríamos o seu apoio para podermos continuar o desenvolvimento desse software no futuro." text3: "Também há benefícios especiais para apoiadores!" +_uploader: + editImage: "Editar Imagem" + compressedToX: "Comprimido para {x}" + savedXPercent: "Salvando {x}%" + abortConfirm: "Alguns arquivos não foram enviados, deseja abortar?" + doneConfirm: "Alguns arquivos não foram enviados, deseja continuar mesmo assim?" + maxFileSizeIsX: "O tamanho máximo de arquivos enviados é {x}" + allowedTypes: "Tipos de arquivo enviáveis" + tip: "O arquivo não foi enviado. Então, esse diálogo permite que você confirme, renomeie, comprima e recorte o arquivo antes de enviar. Quando estiver pronto, você pode enviar apertando o botão \"Enviar\"." _clientPerformanceIssueTip: title: "Dicas de desempenho" makeSureDisabledAdBlocker: "Desative o seu bloqueador de anúncios" @@ -3084,8 +3127,20 @@ _clientPerformanceIssueTip: makeSureDisabledCustomCss_description: "Substituir o estilo da página pode afetar o desempenho. Certifique-se que o CSS personalizado ou extensões que modifiquem o estilo da página estejam desabilitados." makeSureDisabledAddons: "Desabilite extensões" makeSureDisabledAddons_description: "Algumas extensões podem afetar comportamentos do cliente e afetar o desempenho. Por favor, desative as extensões do seu navegador e veja se isso melhora a situação." +_clip: + tip: "Clip é uma função que permite organização das suas notas." +_userLists: + tip: "Listas podem conter qualquer usuário que você especificar em sua criação. A lista criada aparece como uma linha do tempo exibindo usuários selecionados." +watermark: "Marca d'água" +defaultPreset: "Predefinição Padrão" _watermarkEditor: + tip: "Uma marca d'água, como informação de autoria, pode ser adicionada à imagem." + quitWithoutSaveConfirm: "Descartar mudanças?" driveFileTypeWarn: "Esse arquivo não é compatível" + driveFileTypeWarnDescription: "Escolha um arquivo de imagem" + title: "Editar marca d'água" + cover: "Cobrir tudo" + repeat: "Espalhar pelo conteúdo" opacity: "Opacidade" scale: "Tamanho" text: "Texto" @@ -3093,4 +3148,50 @@ _watermarkEditor: type: "Tipo" image: "imagem" advanced: "Avançado" + stripe: "Listras" + stripeWidth: "Largura da linha" + stripeFrequency: "Número de linhas" angle: "Ângulo" + polkadot: "Bolinhas" + checker: "Xadrez" + polkadotMainDotOpacity: "Opacidade da bolinha principal" + polkadotMainDotRadius: "Raio da bolinha principal" + polkadotSubDotOpacity: "Opacidade da bolinha secundária" + polkadotSubDotRadius: "Raio das bolinhas adicionais" + polkadotSubDotDivisions: "Número de bolinhas adicionais" +_imageEffector: + title: "Efeitos" + addEffect: "Adicionar efeitos" + discardChangesConfirm: "Tem certeza que deseja sair? Há mudanças não salvas." + _fxs: + chromaticAberration: "Aberração cromática" + glitch: "Glitch" + mirror: "Espelho" + invert: "Inverter Cores" + grayscale: "Tons de Cinza" + colorAdjust: "Correção de Cores" + colorClamp: "Compressão de Cores" + colorClampAdvanced: "Compressão Avançada de Cores" + distort: "Distorção" + threshold: "Limiarização Binária" + zoomLines: "Linhas de Ação" + stripe: "Listras" + polkadot: "Bolinhas" + checker: "Xadrez" + blockNoise: "Bloquear Ruído" + tearing: "Descontinuidade" +drafts: "Rascunhos" +_drafts: + select: "Selecionar Rascunho" + cannotCreateDraftAnymore: "O número máximo de rascunhos foi excedido." + cannotCreateDraft: "Você não pode criar um rascunho com esse conteúdo." + delete: "Excluir Rascunho" + deleteAreYouSure: "Excluir rascunho?" + noDrafts: "Sem rascunhos" + replyTo: "Resposta a {user}" + quoteOf: "Citação à nota de {user}" + postTo: "Publicando em {channel}" + saveToDraft: "Salvar como Rascunho" + restoreFromDraft: "Restaurar de Rascunho" + restore: "Redefinir" + listDrafts: "Lista de Rascunhos" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index cbcc337745..d6b0ef4de4 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -146,7 +146,7 @@ enterFileName: "พิมพ์ชื่อไฟล์" mute: "ปิดเสียง" unmute: "ยกเลิกการปิดเสียง" renoteMute: "ปิดเสียงรีโน้ต" -renoteUnmute: "เปิดเสียง รีโน้ต" +renoteUnmute: "เลิกปิดเสียงรีโน้ต" block: "บล็อก" unblock: "เลิกบล็อก" suspend: "ระงับ" @@ -242,8 +242,8 @@ silencedInstances: "ปิดปากเซิร์ฟเวอร์นี้ silencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปาก คั่นด้วยการขึ้นบรรทัดใหม่, บัญชีทั้งหมดของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปากเช่นกัน ทำได้เฉพาะคำขอติดตามเท่านั้น และไม่สามารถกล่าวถึงบัญชีในเซิร์ฟเวอร์นี้ได้หากไม่ได้ถูกติดตามกลับ | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก" mediaSilencedInstances: "เซิร์ฟเวอร์ที่ถูกปิดปากสื่อ" mediaSilencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปากสื่อ คั่นด้วยการขึ้นบรรทัดใหม่, ไฟล์ที่ถูกส่งจากบัญชีของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปาก แล้วจะถูกติดเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน และเอโมจิแบบกำหนดเองก็จะใช้ไม่ได้ด้วย | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก" -federationAllowedHosts: "เซิร์ฟเวอร์ที่เปิดให้บริการแบบเฟเดอเรชั่น" -federationAllowedHostsDescription: "ระบุชื่อโฮสต์ของเซิร์ฟเวอร์ที่คุณต้องการอนุญาตให้เชื่อมต่อแบบเฟเดอเรชั่น โดยต้องเว้นวรรคแต่ละบรรทัด" +federationAllowedHosts: "เซิร์ฟเวอร์ที่อนุญาตให้เชื่อมกับสหพันธ์" +federationAllowedHostsDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่อนุญาตให้เชื่อมกับสหพันธ์ โดยแยกแต่ละรายการด้วยบรรทัดใหม่" muteAndBlock: "ปิดเสียงและบล็อก" mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง" blockedUsers: "ผู้ใช้ที่ถูกบล็อก" @@ -298,9 +298,11 @@ uploadFromUrl: "อัปโหลดจาก URL" uploadFromUrlDescription: "URL ของไฟล์ที่คุณต้องการอัปโหลด" uploadFromUrlRequested: "ร้องขอการอัปโหลดแล้ว" uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เวลาสักครู่จึงจะเสร็จสมบูรณ์" +uploadNFiles: "อัปโหลด {n} ไฟล์" explore: "สำรวจ" messageRead: "อ่านแล้ว" noMoreHistory: "ไม่มีประวัติเพิ่มเติม" +startChat: "เริ่มแชต" nUsersRead: "อ่านโดย {n}" agreeTo: "ฉันยอมรับ {0}" agree: "ยอมรับ" @@ -325,6 +327,7 @@ dark: "มืด" lightThemes: "ธีมสว่าง" darkThemes: "ธีมมืด" syncDeviceDarkMode: "ซิงค์โหมดมืดกับการตั้งค่าอุปกรณ์ของคุณ" +switchDarkModeManuallyWhenSyncEnabledConfirm: "“{x}” เปิดอยู่ ต้องการปิดการซิงค์และสลับโหมดด้วยตนเองหรือไม่?" drive: "ไดรฟ์" fileName: "ชื่อไฟล์" selectFile: "เลือกไฟล์" @@ -365,7 +368,7 @@ reject: "ปฏิเสธ" normal: "ปกติ" instanceName: "ชื่อเซิร์ฟเวอร์" instanceDescription: "คำอธิบายแนะนำเซิร์ฟเวอร์" -maintainerName: "ผู้ดูแล" +maintainerName: "ชื่อผู้ดูแลระบบ" maintainerEmail: "อีเมลผู้ดูแลระบบ" tosUrl: "URL เงื่อนไขการให้บริการ" thisYear: "ปีนี้" @@ -423,6 +426,7 @@ antennaExcludeBots: "ยกเว้นบัญชีบอต" antennaKeywordsDescription: "คั่นด้วยเว้นวรรคสำหรับเงื่อนไข AND, หรือขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR" notifyAntenna: "แจ้งเตือนเกี่ยวกับโน้ตใหม่" withFileAntenna: "เฉพาะโน้ตที่มีไฟล์" +excludeNotesInSensitiveChannel: "ไม่รวมโน้ตจากช่องเนื้อหาละเอียดอ่อน" enableServiceworker: "เปิดใช้งานการแจ้งเตือนแบบพุชไปยังเบราว์เซอร์ของคุณ" antennaUsersDescription: "ระบุหนึ่งชื่อผู้ใช้ต่อบรรทัด" caseSensitive: "อักษรพิมพ์ใหญ่-พิมพ์เล็กความหมายต่างกัน" @@ -453,17 +457,17 @@ totpDescription: "ใช้แอปยืนยันตัวตนเพื moderator: "ผู้ควบคุม" moderation: "การกลั่นกรอง" moderationNote: "โน้ตการกลั่นกรอง" -moderationNoteDescription: "คุณสามารถใส่โน้ตส่วนตัวที่เฉพาะผู้ดูแลระบบเท่านั้นที่สามารถเข้าถึงได้" +moderationNoteDescription: "สามารถจดเมโมที่จะแบ่งปันเฉพาะระหว่างผู้ควบคุมได้" addModerationNote: "เพิ่มโน้ตการกลั่นกรอง" moderationLogs: "ปูมการควบคุมดูแล" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} ราย" -securityKeyAndPasskey: "ความปลอดภัยและรหัสผ่าน" -securityKey: "กุญแจความปลอดภัย" +securityKeyAndPasskey: "Security key และ Passkey" +securityKey: "Security Key" lastUsed: "ใช้ล่าสุด" lastUsedAt: "ใช้งานครั้งล่าสุด: {t}" unregister: "เลิกติดตาม" passwordLessLogin: "เข้าสู่ระบบแบบไม่ใช้รหัสผ่าน" -passwordLessLoginDescription: "อนุญาตให้เข้าสู่ระบบโดยไม่ต้องใช้รหัสผ่านโดยใช้รหัสรักษาความปลอดภัยหรือรหัสผ่านเท่านั้น" +passwordLessLoginDescription: "เข้าสู่ระบบโดยไม่ใช้รหัสผ่าน โดยใช้เฉพาะ Security Key หรือ Passkey เท่านั้น" resetPassword: "รีเซ็ตรหัสผ่าน" newPasswordIs: "รหัสผ่านใหม่คือ “{password}”" reduceUiAnimation: "ลดภาพเคลื่อนไหว UI" @@ -573,8 +577,10 @@ showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ showFixedPostFormInChannel: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนของไทม์ไลน์ (ช่อง)" withRepliesByDefaultForNewlyFollowed: "แสดงการตอบกลับจากผู้ใช้ที่คุณเพิ่งติดตามลงไทม์ไลน์ตามค่าเริ่มต้น" newNoteRecived: "มีโน้ตใหม่" +newNote: "โน้ตใหม่" sounds: "เสียง" sound: "เสียง" +notificationSoundSettings: "ตั้งค่าเสียงแจ้งเตือน" listen: "ฟัง" none: "ไม่มี" showInPage: "แสดงในเพจ" @@ -584,6 +590,7 @@ masterVolume: "ระดับเสียงหลัก" notUseSound: "ไม่ใช้เสียง" useSoundOnlyWhenActive: "มีเสียงออกเฉพาะตอนกำลังใช้ Misskey อยู่เท่านั้น" details: "รายละเอียด" +renoteDetails: "รายละเอียดรีโน้ต" chooseEmoji: "เลือกเอโมจิ" unableToProcess: "ไม่สามารถดำเนินการให้เสร็จสิ้นได้" recentUsed: "ใช้ล่าสุด" @@ -605,8 +612,8 @@ output: "เอาท์พุต" script: "สคริปต์" disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ" updateRemoteUser: "อัปเดตข้อมูลผู้ใช้งานระยะไกล" -unsetUserAvatar: "เลิกตั้งอวตาร" -unsetUserAvatarConfirm: "ต้องการเลิกตั้งอวตารใข่ไหม?" +unsetUserAvatar: "เลิกตั้งไอคอน" +unsetUserAvatarConfirm: "ต้องการเลิกตั้งไอคอนประจำตัวหรือไม่?" unsetUserBanner: "เลิกตั้งแบนเนอร์" unsetUserBannerConfirm: "ต้องการเลิกตั้งแบนเนอร์?" deleteAllFiles: "ลบไฟล์ทั้งหมด" @@ -681,16 +688,19 @@ smtpSecure: "ใช้โดยนัย SSL/TLS สำหรับการเ smtpSecureInfo: "ปิดสิ่งนี้เมื่อใช้ STARTTLS" testEmail: "ทดสอบการส่งอีเมล" wordMute: "ปิดเสียงคำ" +wordMuteDescription: "ย่อโน้ตที่มีวลีที่ระบุ สามารถดูโน้ตที่ย่อแล้วได้โดยคลิกที่โน้ตเหล่านั้น" hardWordMute: "ปิดเสียงคำแบบแข็งโป๊ก" -hardWordMuteDescription: "ซ่อนหมายเหตุที่มีวลีที่ระบุ ต่างจากการปิดเสียงคำ โน้ตต่างๆ จะถูกซ่อนไว้อย่างสมบูรณ์" +showMutedWord: "แสดงคำที่ถูกปิดเสียง" +hardWordMuteDescription: "จะซ่อนโน้ตที่มีคำที่ระบุไว้ ซึ่งไม่เหมือนการปิดเสียงคำ ในกรณีนี้โน้ตจะไม่แสดงเลย" regexpError: "เกิดข้อผิดพลาดใน regular expression" regexpErrorDescription: "เกิดข้อผิดพลาดใน regular expression บรรทัดที่ {line} ของการปิดเสียงคำ {tab} :" instanceMute: "ปิดเสียงเซิร์ฟเวอร์" userSaysSomething: "{name} พูดอะไรบางอย่าง" -userSaysSomethingAbout: "{name} พูดอะไรบางอย่างเกี่ยวกับ \"{word}\"" +userSaysSomethingAbout: "{name} พูดบางอย่างเกี่ยวกับ “{word}”" makeActive: "เปิดใช้งาน" display: "แสดงผล" copy: "คัดลอก" +copiedToClipboard: "คัดลอกไปยังคลิปบอร์ดแล้ว" metrics: "เมตริก" overview: "ภาพรวม" logs: "ปูม" @@ -756,7 +766,7 @@ yes: "ใช่" no: "ไม่" driveFilesCount: "จำนวนไฟล์ไดรฟ์" driveUsage: "การใช้พื้นที่ไดรฟ์" -noCrawle: "ปฏิเสธการจัดทำดัชนีของโปรแกรมรวบรวมข้อมูล" +noCrawle: "ปฏิเสธการจัดทำดัชนีของ Crawler (โปรแกรมรวบรวมข้อมูล)" noCrawleDescription: "ขอให้เครื่องมือค้นหาไม่จัดทำดัชนีหน้าโปรไฟล์ โน้ต หน้าเพจ ฯลฯ" lockedAccountInfo: "แม้ว่าการอนุมัติการติดตามถูกเปิดใช้งานอยู่ทุกคนก็ยังคงสามารถเห็นโน้ตของคุณได้ เว้นแต่ว่าคุณจะเปลี่ยนการเปิดเผยโน้ตของคุณเป็น “เฉพาะผู้ติดตาม”" alwaysMarkSensitive: "ทำเครื่องหมายว่ามีเนื้อหาละเอียดอ่อนเป็นค่าเริ่มต้น" @@ -785,6 +795,7 @@ wide: "กว้าง" narrow: "ชิด" reloadToApplySetting: "การตั้งค่านี้จะมีผลหลังจากโหลดหน้าซ้ำเท่านั้น ต้องการที่จะโหลดใหม่เลยไหม?" needReloadToApply: "ต้องรีโหลดเพื่อให้การเปลี่ยนแปลงมีผล" +needToRestartServerToApply: "จำเป็นต้องรีสตาร์ทเซิร์ฟเวอร์เพื่อให้การเปลี่ยนแปลงมีผล" showTitlebar: "แสดงแถบชื่อ" clearCache: "ล้างแคช" onlineUsersCount: "{n} รายกำลังออนไลน์" @@ -878,7 +889,7 @@ previewNoteText: "แสดงตัวอย่าง" customCss: "CSS ที่กำหนดเอง" customCssWarn: "ควรใช้การตั้งค่านี้เฉพาะต่อเมื่อคุณรู้มันใช้ทำอะไร การตั้งค่าที่ไม่เหมาะสมอาจทำให้ไคลเอ็นต์ไม่สามารถใช้งานได้อย่างถูกต้อง" global: "ทั่วโลก" -squareAvatars: "แสดงผลอวตารเป็นสี่เหลี่ยม" +squareAvatars: "แสดงไอคอนประจำตัวเป็นสี่เหลี่ยม" sent: "ส่ง" received: "ได้รับแล้ว" searchResult: "ผลการค้นหา" @@ -945,6 +956,9 @@ oneHour: "1 ชั่วโมง" oneDay: "1 วัน" oneWeek: "1 สัปดาห์" oneMonth: "หนึ่งเดือน" +threeMonths: "3 เดือน" +oneYear: "1 ปี" +threeDays: "3 วัน" reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ" failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้" rateLimitExceeded: "เกินขีดจำกัดอัตรา" @@ -969,6 +983,7 @@ document: "เอกสาร" numberOfPageCache: "จำนวนหน้าเพจที่แคช" numberOfPageCacheDescription: "การเพิ่มจำนวนนี้จะช่วยเพิ่มความสะดวกให้กับผู้ใช้งาน แต่จะทำให้เซิร์ฟเวอร์โหลดมากขึ้นและต้องใช้หน่วยความจำมากขึ้นอีกด้วย" logoutConfirm: "ต้องการออกจากระบบใช่ไหม?" +logoutWillClearClientData: "เมื่อออกจากระบบ ข้อมูลการตั้งค่าของไคลเอนต์จะถูกลบออกจากเบราว์เซอร์ เพื่อให้สามารถกู้คืนข้อมูลการตั้งค่าได้เมื่อกลับมาเข้าสู่ระบบอีกครั้ง โปรดเปิดใช้งานการสำรองข้อมูลการตั้งค่าอัตโนมัติ" lastActiveDate: "ใช้งานล่าสุดเมื่อ" statusbar: "แถบสถานะ" pleaseSelect: "ตัวเลือก" @@ -987,6 +1002,7 @@ failedToUpload: "การอัปโหลดล้มเหลว" cannotUploadBecauseInappropriate: "ไม่สามารถอัปโหลดไฟล์นี้ได้เนื่องจากระบบตรวจพบบางส่วนของไฟล์ว่านี้อาจจะเป็น NSFW" cannotUploadBecauseNoFreeSpace: "ไม่สามารถอัปโหลดได้เนื่องจากไม่มีพื้นที่ว่างในไดรฟ์เหลือแล้ว" cannotUploadBecauseExceedsFileSizeLimit: "ไม่สามารถอัปโหลดไฟล์นี้ได้แล้วเนื่องจากเกินขีดจำกัดของขนาดไฟล์แล้ว" +cannotUploadBecauseUnallowedFileType: "ไม่สามารถอัปโหลดได้เนื่องจากเป็นชนิดไฟล์ที่ไม่ได้รับอนุญาต" beta: "เบต้า" enableAutoSensitive: "ทำเครื่องหมายว่ามีเนื้อหาที่ละเอียดอ่อนโดยอัตโนมัติ" enableAutoSensitiveDescription: "อนุญาตให้ตรวจหาและทำเครื่องหมายสื่อว่ามีเนื้อหาโดยละเอียดอ่อนโดยอัตโนมัติ ผ่าน Machine Learning หากเป็นไปได้ แม้ว่าคุณจะปิดคุณสมบัตินี้ ก็อาจถูกตั้งค่าโดยอัตโนมัติ ทั้งนี้ขึ้นอยู่กับเซิร์ฟเวอร์" @@ -1006,7 +1022,7 @@ windowMaximize: "ขยายใหญ่สุด" windowMinimize: "ย่อเล็กที่สุด" windowRestore: "เลิกทำ" caption: "คำอธิบาย" -loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้" +loggedInAsBot: "เข้าสู่ระบบเป็นบอตอยู่ในขณะนี้" tools: "เครื่องมือ" cannotLoad: "ไม่สามารถโหลดได้" numberOfProfileView: "มุมมองโปรไฟล์" @@ -1055,7 +1071,7 @@ exploreOtherServers: "มองหาเซิร์ฟเวอร์อื่ letsLookAtTimeline: "มาดูไทม์ไลน์กัน" disableFederationConfirm: "ปิดใช้งานสหพันธ์เลยใช่ไหม?" disableFederationConfirmWarn: "โพสต์จะยังคงเป็นสาธารณะต่อไป เว้นแต่จะตั้งค่าเป็นอย่างอื่น" -disableFederationOk: "ปิดการใช้งาน" +disableFederationOk: "ปิดการใช้งานสหพันธ์" invitationRequiredToRegister: "เซิร์ฟเวอร์นี้เป็นแบบรับเชิญ เฉพาะผู้มีรหัสเชิญเท่านั้นถึงสามารถลงทะเบียนได้" emailNotSupported: "เซิร์ฟเวอร์นี้ไม่รองรับการส่งอีเมล" postToTheChannel: "โพสต์ลงช่อง" @@ -1085,7 +1101,7 @@ retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริ retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" enableChartsForFederatedInstances: "สร้างแผนภูมิของเซิร์ฟเวอร์ระยะไกล" -enableStatsForFederatedInstances: "ดึงข้อมูลสถิติจากเซิร์ฟเวอร์ที่อยู่ห่างไกล" +enableStatsForFederatedInstances: "ดึงข้อมูลจากเซิร์ฟเวอร์ระยะไกล" showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต" reactionsDisplaySize: "ขนาดของรีแอคชั่น" limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง" @@ -1216,13 +1232,13 @@ impressumDescription: "การติดป้ายกำกับ (Impressum) privacyPolicy: "นโยบายความเป็นส่วนตัว" privacyPolicyUrl: "URL นโยบายความเป็นส่วนตัว" tosAndPrivacyPolicy: "เงื่อนไขในการให้บริการและนโยบายความเป็นส่วนตัว" -avatarDecorations: "การตกแต่งอวตาร" +avatarDecorations: "ของตกแต่งไอคอน" attach: "แนบ" detach: "นำออก" detachAll: "เอาออกทั้งหมด" angle: "แองเกิล" flip: "พลิก" -showAvatarDecorations: "แสดงตกแต่งอวตาร" +showAvatarDecorations: "แสดงของตกแต่งไอคอน" releaseToRefresh: "ปล่อยเพื่อรีเฟรช" refreshing: "กำลังรีเฟรช..." pullDownToRefresh: "ดึงลงเพื่อรีเฟรช" @@ -1278,51 +1294,208 @@ clipNoteLimitExceeded: "ไม่สามารถเพิ่มโน้ต performance: "ประสิทธิภาพ​" modified: "แก้ไข" discard: "ละทิ้ง" -thereAreNChanges: "มีอยู่ {n} เปลี่ยนแปลง(s)" +thereAreNChanges: "มีการเปลี่ยนแปลง {n} รายการ" signinWithPasskey: "ลงชื่อเข้าใช้ด้วย Passkey" -unknownWebAuthnKey: "พาสคีย์ไม่ถูกต้องค่ะ" -passkeyVerificationFailed: "การยืนยันกุญแจดิจิทัลไม่สำเร็จค่ะ" -passkeyVerificationSucceededButPasswordlessLoginDisabled: "การยืนยันพาสคีย์สำเร็จแล้ว แต่การลงชื่อเข้าใช้แบบไม่ต้องใส่รหัสผ่านถูกปิดใช้งานแล้ว" +unknownWebAuthnKey: "เป็น Passkey ที่ยังไม่ได้ลงทะเบียน" +passkeyVerificationFailed: "การยืนยัน Passkey ล้มเหลว" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "การยืนยัน Passkey สำเร็จ แต่การเข้าสู่ระบบแบบไม่ใช้รหัสผ่านถูกปิดใช้งานอยู่" messageToFollower: "ข้อความถึงผู้ติดตาม" target: "เป้า" testCaptchaWarning: "ฟังก์ชันนี้มีไว้สำหรับทดสอบ CAPTCHA เท่านั้น\nห้ามนำไปใช้ในระบบจริงโดยเด็ดขาด" prohibitedWordsForNameOfUser: "คำนี้ไม่สามารถใช้เป็นชื่อผู้ใช้ได้" -prohibitedWordsForNameOfUserDescription: "หากมีสตริงใดๆ ในรายการนี้ปรากฏอยู่ในชื่อของผู้ใช้ ชื่อนั้นจะถูกปฏิเสธ ผู้ใช้ที่มีสิทธิ์แต่ผู้ดูแลระบบนั้นจะไม่ได้รับผลกระทบใดๆจากข้อจำกัดนี้ค่ะ" +prohibitedWordsForNameOfUserDescription: "จะไม่อนุญาตให้เปลี่ยนชื่อผู้ใช้หากชื่อของผู้ใช้มีข้อความที่อยู่ในรายการนี้ แต่ผู้ใช้ที่มีสิทธิ์เป็นผู้ควบคุมจะไม่ได้รับผลกระทบจากข้อจำกัดนี้" yourNameContainsProhibitedWords: "ชื่อของคุณนั้นมีคำที่ต้องห้าม" yourNameContainsProhibitedWordsDescription: "ถ้าหากคุณต้องการใช้ชื่อนี้ กรุณาติดต่อผู้ดูแลระบบของเซิร์ฟเวอร์นะค่ะ" -federationDisabled: "เซิร์ฟเวอร์นี้ปิดการใช้งานการรวมกลุ่ม คุณไม่สามารถโต้ตอบกับผู้ใช้บนเซิร์ฟเวอร์อื่นได้" -reactAreYouSure: "คุณต้องการที่จะตอบสนองต่อ \" {emoji}\" หรือไม่?" -markAsSensitiveConfirm: "คุณต้องการทำเครื่องหมายสื่อนี้ว่าละเอียดอ่อนหรือไม่?" -unmarkAsSensitiveConfirm: "คุณต้องการลบการกำหนดความไวของสื่อนี้หรือไม่?" +thisContentsAreMarkedAsSigninRequiredByAuthor: "ผู้โพสต์ได้ตั้งค่าว่าต้องเข้าสู่ระบบจึงจะสามารถดูได้" +lockdown: "ล็อกดาวน์" +pleaseSelectAccount: "โปรดเลือกบัญชี" +availableRoles: "บทบาทที่ใช้ได้" +acknowledgeNotesAndEnable: "เปิดใช้งานหลังจากที่เข้าใจข้อควรระวังแล้ว" +federationSpecified: "เซิร์ฟเวอร์นี้ดำเนินงานในระบบกลุ่มไวท์ลิสต์ ไม่สามารถติดต่อกับเซิร์ฟเวอร์อื่นที่ไม่ได้รับอนุญาตจากผู้ดูแลระบบได้" +federationDisabled: "เซิร์ฟเวอร์นี้ปิดใช้งานสหพันธ์ ไม่สามารถติดต่อหรือแลกเปลี่ยนข้อมูลกับผู้ใช้จากเซิร์ฟเวอร์อื่นได้" +draft: "ร่าง" +confirmOnReact: "ยืนยันเมื่อทำการรีแอคชั่น" +reactAreYouSure: "ต้องการใส่รีแอคชั่นด้วย \"{emoji}\" หรือไม่?" +markAsSensitiveConfirm: "ต้องการตั้งค่าสื่อนี้ว่าเป็นเนื้อหาละเอียดอ่อนหรือไม่?" +unmarkAsSensitiveConfirm: "ต้องการยกเลิกการระบุว่าสื่อนี้มีเนื้อหาละเอียดอ่อนหรือไม่?" preferences: "การตั้งค่าสภาพแวดล้อม" +accessibility: "การช่วยการเข้าถึง" preferencesProfile: "โปรไฟล์การกำหนดค่า" +copyPreferenceId: "คัดลือก ID การตั้งค่า" +resetToDefaultValue: "คืนค่าเป็นค่าเริ่มต้น" +overrideByAccount: "เขียนทับด้วยบัญชี" +untitled: "ไม่มีชื่อ" +noName: "ไม่มีชื่อ" +skip: "ข้าม" +restore: "กู้คืน" +syncBetweenDevices: "ซิงค์ระหว่างอุปกรณ์" preferenceSyncConflictTitle: "การตั้งค่ามีอยู่บนเซิร์ฟเวอร์" -preferenceSyncConflictText: "รายการการตั้งค่าที่เปิดใช้งานการซิงโครไนซ์จะจัดเก็บค่าไว้บนเซิร์ฟเวอร์ และพบค่าที่จัดเก็บบนเซิร์ฟเวอร์สำหรับรายการการตั้งค่านี้ คุณต้องการทำอย่างไร?" +preferenceSyncConflictText: "การตั้งค่าที่เปิดใช้งานการซิงค์จะบันทึกค่าลงในเซิร์ฟเวอร์ อย่างไรก็ดี พบว่ามีค่าการตั้งค่านี้ที่เคยบันทึกไว้ในเซิร์ฟเวอร์แล้ว ต้องการดำเนินการอย่างไร?" +preferenceSyncConflictChoiceMerge: "รวมเข้าด้วยกัน" +preferenceSyncConflictChoiceServer: "เขียนทับด้วยค่าการตั้งค่าเซิร์ฟเวอร์" +preferenceSyncConflictChoiceDevice: "เขียนทับด้วยค่าการตั้งค่าอุปกรณ์" +preferenceSyncConflictChoiceCancel: "ยกเลิกการเปิดใช้งานการซิงค์" +paste: "วาง" +emojiPalette: "จานสีเอโมจิ" postForm: "แบบฟอร์มการโพสต์" +textCount: "จำนวนอักขระ" information: "เกี่ยวกับ" +chat: "แชต" +migrateOldSettings: "ย้ายข้อมูลการตั้งค่าเก่า" +migrateOldSettings_description: "โดยปกติจะทำโดยอัตโนมัติ แต่หากด้วยเหตุผลบางประการที่ไม่สามารถย้ายได้สำเร็จ สามารถสั่งย้ายด้วยตนเองได้ การตั้งค่าปัจจุบันจะถูกเขียนทับ" +compress: "บีบอัด" right: "ขวา" bottom: "ภายใต้" +top: "บน" +embed: "ฝัง" +settingsMigrating: "กำลังย้ายการตั้งค่า กรุณารอสักครู่... (สามารถย้ายด้วยตนเองภายหลังได้ที่ การตั้งค่า → อื่นๆ → ย้ายข้อมูลการตั้งค่าเก่า)" +readonly: "อ่านได้อย่างเดียว" +goToDeck: "กลับไปยังเด็ค" +federationJobs: "งานสหพันธ์" +driveAboutTip: "ในไดรฟ์จะแสดงรายการไฟล์ที่เคยอัปโหลดไว้ก่อนหน้า
\nสามารถนำมาใช้ซ้ำเมื่อแนบไฟล์ในโน้ต หรือตั้งค่าให้อัปโหลดไฟล์ล่วงหน้าเพื่อนำไปโพสต์ทีหลังได้
\nโปรดระวัง เมื่อลบไฟล์ ไฟล์นั้นจะไม่แสดงในทุกที่ที่เคยใช้ไฟล์นี้ (โน้ต, หน้าเพจ, อวตาร, แบนเนอร์ ฯลฯ)
\nสามารถสร้างโฟลเดอร์เพื่อจัดระเบียบได้" +scrollToClose: "เลื่อนเพื่อปิด" +advice: "คำแนะนำ" +realtimeMode: "โหมดเรียลไทม์" +turnItOn: "เปิดใช้งาน" +turnItOff: "ปิดใช้งาน" +emojiMute: "ปิดเสียงเอโมจิ" +emojiUnmute: "เลิกปิดเสียงเอโมจิ" +muteX: "ปิดเสียง {x}" +unmuteX: "เลิกปิดเสียง {x}" +abort: "หยุดและยกเลิก" +tip: "คำแนะนำและเคล็ดลับ" +redisplayAllTips: "แสดงคำแนะนำและเคล็ดลับทั้งหมดอีกครั้ง" +hideAllTips: "ซ่อนคำแนะนำและเคล็ดลับทั้งหมด" +defaultImageCompressionLevel: "ความละเอียดเริ่มต้นสำหรับการบีบอัดภาพ" +defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น
หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง" +_order: + newest: "เรียงจากใหม่ไปเก่า" + oldest: "เรียงจากเก่าไปใหม่" _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: "แชตใช้งานไม่ได้ หรือคู่สนทนายังไม่ได้เปิดแชต" + youAreNotAMemberOfThisRoomButInvited: "คุณไม่ได้เป็นผู้เข้าร่วมห้องนี้ แต่มีคำเชิญส่งมา หากต้องการเข้าร่วม กรุณายืนยันคำเชิญ" + doYouAcceptInvitation: "ต้องการยอมรับคำเชิญหรือไม่?" + 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: "สามารถจัดการและตั้งค่า Access Token และ Webhook เพื่อเชื่อมต่อกับแอปหรือบริการภายนอกได้" + accountData: "ข้อมูลบัญชี" + accountDataBanner: "สามารถจัดการข้อมูลบัญชีได้โดยส่งออกหรือนำเข้าไฟล์เก็บถาวร" + muteAndBlockBanner: "สามารถตั้งค่าการซ่อนเนื้อหา และจำกัดการกระทำจากผู้ใช้เฉพาะรายได้" + accessibilityBanner: "สามารถปรับแต่งรูปลักษณ์และพฤติกรรมของไคลเอนต์เพื่อให้เหมาะกับการใช้งานของตนเองมากขึ้น" + privacyBanner: "สามารถตั้งค่าความเป็นส่วนตัวของบัญชี เช่น ขอบเขตการเผยแพร่เนื้อหา ความสามารถในการค้นหา และการอนุมัติผู้ติดตาม" + securityBanner: "สามารถตั้งค่าความปลอดภัยของบัญชี เช่น รหัสผ่าน วิธีการเข้าสู่ระบบ แอปยืนยันตัวตน Passkey เป็นต้น" preferencesBanner: "คุณสามารถกำหนดค่าพฤติกรรมโดยรวมของไคลเอนต์ได้ตามความต้องการของคุณ" + appearanceBanner: "สามารถตั้งค่ารูปลักษณ์และวิธีการแสดงผลของไคลเอนต์ตามความชอบได้" + soundsBanner: "สามารถตั้งค่าเสียงที่จะเล่นบนไคลเอนต์ได้" + timelineAndNote: "ไทม์ไลน์และโน้ต" + makeEveryTextElementsSelectable: "อนุญาตให้เลือกข้อความทั้งหมดได้" + makeEveryTextElementsSelectable_description: "หากเปิดใช้งาน อาจทำให้ความสะดวกในการใช้งานลดลงในบางสถานการณ์" + useStickyIcons: "ทำให้ไอคอนเคลื่อนตามการเลื่อน" + enableHighQualityImagePlaceholders: "แสดงภาพตัวแทนคุณภาพสูง" + uiAnimations: "ภาพเคลื่อนไหวของ UI" + showNavbarSubButtons: "แสดงปุ่มรองบนแถบนำทาง" + ifOn: "เมื่อเปิดใช้งาน" + ifOff: "เมื่อปิดใช้งาน" + enableSyncThemesBetweenDevices: "ซิงค์ธีมที่ติดตั้งระหว่างอุปกรณ์" + enablePullToRefresh: "ดึงเพื่ออัปเดต" + enablePullToRefresh_description: "สำหรับเมาส์ ให้กดปุ่มล้อกลางค้างไว้แล้วลาก" + realtimeMode_description: "เชื่อมต่อกับเซิร์ฟเวอร์และอัปเดตเนื้อหาแบบเรียลไทม์ อาจทำให้ใช้ปริมาณข้อมูลและแบตเตอรี่มากขึ้นได้" + contentsUpdateFrequency: "ความถี่ในการดึงข้อมูลเนื้อหา" + contentsUpdateFrequency_description: "ยิ่งตั้งค่าสูง เนื้อหาจะอัปเดตแบบเรียลไทม์มากขึ้น แต่ประสิทธิภาพอาจลดลง และการใช้ข้อมูลกับแบตเตอรี่จะเพิ่มมากขึ้น" + contentsUpdateFrequency_description2: "เมื่อโหมดเรียลไทม์เปิดอยู่ เนื้อหาจะอัปเดตแบบเรียลไทม์โดยไม่ขึ้นกับการตั้งค่านี้" + showUrlPreview: "แสดงตัวอย่าง URL" + showAvailableReactionsFirstInNote: "แสดงรีแอคชั่นที่ใช้ได้ไว้หน้าสุด" + _chat: + showSenderName: "แสดงชื่อผู้ส่ง" + sendOnEnter: "กด Enter เพื่อส่ง" +_preferencesProfile: + profileName: "ชื่อโปรไฟล์" + profileNameDescription: "กรุณาตั้งชื่อเพื่อระบุอุปกรณ์นี้" + profileNameDescription2: "เช่น: “คอมเครื่องหลัก”, “มือถือ” ฯลฯ" + manageProfiles: "จัดการโปรไฟล์" +_preferencesBackup: + autoBackup: "สำรองโดยอัตโนมัติ" + restoreFromBackup: "คืนค่าจากข้อมูลสำรอง" + noBackupsFoundTitle: "ไม่พบข้อมูลสำรอง" + noBackupsFoundDescription: "ไม่พบข้อมูลสำรองที่สร้างโดยอัตโนมัติ แต่หากมีข้อมูลสำรองที่บันทึกด้วยตนเอง สามารถนำเข้ามาเพื่อกู้คืนได้" + selectBackupToRestore: "กรุณาเลือกข้อมูลสำรองที่ต้องการกู้คืน" + youNeedToNameYourProfileToEnableAutoBackup: "จำเป็นต้องตั้งชื่อโปรไฟล์ก่อนจึงจะเปิดใช้งานการสำรองข้อมูลอัตโนมัติได้" + autoPreferencesBackupIsNotEnabledForThisDevice: "ยังไม่ได้เปิดใช้งานการสำรองข้อมูลอัตโนมัติบนอุปกรณ์นี้" + backupFound: "พบข้อมูลสำรองของการตั้งค่าแล้ว" _accountSettings: requireSigninToViewContents: "ต้องเข้าสู่ระบบเพื่อดูเนื้อหา" - requireSigninToViewContentsDescription1: "ต้องเข้าสู่ระบบเพื่อดูบันทึกและเนื้อหาอื่น ๆ ทั้งหมดที่คุณสร้าง คาดว่าจะมีประสิทธิผลในการป้องกันไม่ให้ข้อมูลถูกเก็บรวบรวมโดยโปรแกรมรวบรวมข้อมูล" - requireSigninToViewContentsDescription2: "นอกจากนี้ จะไม่สามารถดูจากเซิร์ฟเวอร์ที่ไม่รองรับการดูตัวอย่าง URL (OGP), การฝังในหน้าเว็บ หรือการอ้างอิงหมายเหตุได้" - requireSigninToViewContentsDescription3: "เนื้อหาที่ถูกรวมเข้ากับเซิร์ฟเวอร์ระยะไกลอาจไม่อยู่ภายใต้ข้อจำกัดเหล่านี้" + requireSigninToViewContentsDescription1: "กำหนดให้ต้องเข้าสู่ระบบก่อนจึงจะสามารถดูโน้ตหรือเนื้อหาทั้งหมดที่สร้างไว้ได้ ซึ่งช่วยป้องกันไม่ให้ข้อมูลถูกเก็บโดยบอตหรือ Crawler (โปรแกรมรวบรวมข้อมูล)" + requireSigninToViewContentsDescription2: "จะไม่สามารถแสดงผลจากเซิร์ฟเวอร์ที่ไม่รองรับการแสดงตัวอย่าง URL (OGP), การฝังในหน้าเว็บ, หรือการอ้างอิงโน้ตได้" + requireSigninToViewContentsDescription3: "เนื้อหาที่ถูกรวมผ่านสหพันธ์จากเซิร์ฟเวอร์ระยะไกลอาจไม่อยู่ภายใต้ข้อจำกัดเหล่านี้" + makeNotesFollowersOnlyBefore: "แสดงโน้ตเก่าเฉพาะกับผู้ติดตามเท่านั้น" + makeNotesFollowersOnlyBeforeDescription: "ขณะที่เปิดฟังก์ชันนี้ โน้ตที่เก่ากว่าหรือเลยเวลาที่กำหนดจะแสดงเฉพาะกับผู้ติดตามเท่านั้น หากปิดใช้งาน สถานะการเปิดเผยจะกลับไปเป็นแบบเดิม" + makeNotesHiddenBefore: "ทำให้โน้ตเก่าทั้งหมดเป็นแบบส่วนตัว" + makeNotesHiddenBeforeDescription: "ขณะที่เปิดฟังก์ชันนี้ โน้ตที่เก่ากว่าหรือเลยเวลาที่กำหนดจะแสดงเฉพาะกับตนเอง (กลายเป็นแบบส่วนตัว) หากปิดใช้งาน สถานะการเปิดเผยจะกลับไปเป็นแบบเดิม" + mayNotEffectForFederatedNotes: "โน้ตที่ถูกรวมผ่านสหพันธ์จากเซิร์ฟเวอร์ระยะไกลอาจไม่ได้รับผลจากการตั้งค่านี้" + mayNotEffectSomeSituations: "ข้อจำกัดเหล่านี้เป็นเพียงการกรองเบื้องต้น ในบางกรณี เช่น การดูจากเซิร์ฟเวอร์อื่นหรือในระหว่างการตรวจสอบโดยผู้ดูแล อาจไม่สามารถใช้งานได้" + notesHavePassedSpecifiedPeriod: "โน้ตที่เลยเวลาที่กำหนดไว้แล้ว" + notesOlderThanSpecifiedDateAndTime: "โน้ตก่อนเวลาที่กำหนดไว้" _abuseUserReport: forward: "ส่ง​ต่อ" forwardDescription: "ส่งรายงานไปยังเซิร์ฟเวอร์ระยะไกลโดยใช้บัญชีระบบที่ไม่ระบุตัวตน" resolve: "แก้ไข" accept: "ยอมรับ" reject: "ปฏิเสธ" - resolveTutorial: "ถ้าหากรายงานนี้มีเนื้อหาถูกต้อง ให้เลือก \"ยอมรับ\" เพื่อปิดเคสกรณีนี้โดยถือว่าได้รับการแก้ไขแล้ว\nถ้าหากเนื้อหาในรายงานนี้นั้นไม่ถูกต้อง ให้เลือก \"ปฏิเสธ\" เพื่อปิดเคสกรณีนี้โดยถือว่าไม่ได้รับการแก้ไข" + resolveTutorial: "ให้เลือก “ยอมรับ” หากรายงานนี้มีเนื้อหาชอบธรรม เพื่อทำเครื่องหมายว่ากรณีนี้ได้รับการแก้ไขในทางบวก\nให้เลือก “ปฏิเสธ” หากรายงานนี้มีเนื้อหาไม่สมเหตุผล เพื่อทำเครื่องหมายว่ากรณีนี้ได้รับการแก้ไขในทางลบ" _delivery: status: "สถานะการจัดส่ง" stop: "ระงับการส่ง" @@ -1332,6 +1505,7 @@ _delivery: manuallySuspended: "หยุดชั่วคราวด้วยตนเอง" goneSuspended: "เซิร์ฟเวอร์ถูกระงับเนื่องจากมีการลบเซิร์ฟเวอร์นี้" autoSuspendedForNotResponding: "เซิร์ฟเวอร์ถูกระงับเนื่องจากไม่ตอบสนอง" + softwareSuspended: "หยุดให้บริการ เนื่องจากเป็นซอฟต์แวร์ที่ถูกระงับการเผยแพร่" _bubbleGame: howToPlay: "วิธีเล่น" hold: "ถือไว้" @@ -1446,7 +1620,7 @@ _timelineDescription: _serverRules: description: "ชุดของกฎที่จะแสดงก่อนการลงทะเบียนเราขอแนะนำให้ตั้งค่าสรุปข้อกำหนดในการให้บริการ" _serverSettings: - iconUrl: "URL ไอคอน" + iconUrl: "URL ของไอคอน" appIconDescription: "ระบุไอคอนที่จะใช้เมื่อ {host} แสดงเป็นแอป" appIconUsageExample: "ตัวอย่างเช่น เมื่อถูกเพิ่มเป็น PWA หรือบุ๊กมาร์กบนหน้าจอหลักในสมาร์ทโฟน" appIconStyleRecommendation: "เนื่องจากอาจถูกครอบตัดเป็นสี่เหลี่ยมหรือวงกลม จึงแนะนำให้ใช้ภาพที่เผื่อพื้นที่รอบๆ ตัวโลโก้ไอคอนไว้" @@ -1460,7 +1634,26 @@ _serverSettings: reactionsBufferingDescription: "เมื่อเปิดใช้งานฟังก์ชันนี้ก็จะช่วยลด latency ในการสร้างปฏิกิริยา แต่อาจจะส่งผลให้ memory footprint ของ Redis เพิ่มขึ้นนะ" inquiryUrl: "URL สำหรับการติดต่อสอบถาม" inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์" - thisSettingWillAutomaticallyOffWhenModeratorsInactive: "ถ้าหากไม่มีการตรวจสอบจากผู้ดูแลระบบหรือไม่มีความเคลื่อนไหวมาเป็นระยะเวลาหนึ่ง ระบบจะทำการปิดใช้งานฟังก์ชันนี้โดยอัตโนมัติ เพื่อลดความเสี่ยงในการถูกโจมตีด้วยสแปมและอื่นๆ" + openRegistration: "เปิดให้สร้างบัญชีได้" + openRegistrationWarning: "การเปิดให้ลงทะเบียนมีความเสี่ยง แนะนำให้เปิดใช้งานเฉพาะในกรณีที่สามารถตรวจสอบเซิร์ฟเวอร์อย่างสม่ำเสมอและมีระบบรับมือกับปัญหาได้ทันท่วงที" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "หากไม่พบกิจกรรมของผู้ควบคุมในช่วงระยะเวลาหนึ่ง การตั้งค่านี้จะถูกปิดโดยอัตโนมัติเพื่อป้องกันสแปม" + deliverSuspendedSoftware: "ซอฟต์แวร์ที่หยุดการเผยแพร่" + deliverSuspendedSoftwareDescription: "เนื่องจากเหตุผลด้านช่องโหว่ เป็นต้น สามารถหยุดการแจกจ่ายโดยระบุชื่อซอฟต์แวร์ของเซิร์ฟเวอร์และช่วงของเวอร์ชันได้ ข้อมูลเวอร์ชันนี้เป็นข้อมูลที่เซิร์ฟเวอร์ให้มา จึงไม่สามารถรับประกันความน่าเชื่อถือได้ สามารถใช้การระบุช่วงเวอร์ชันแบบ semver ได้ แต่ถ้าระบุเป็น >= 2024.3.1 จะไม่รวมเวอร์ชันแบบกำหนดเอง เช่น 2024.3.1-custom.0 จึงแนะนำให้ระบุเป็น >= 2024.3.1-0 ซึ่งเป็นการระบุแบบ prerelease" + singleUserMode: "โหมดผู้ใช้คนเดียว" + singleUserMode_description: "หากมีเพียงตัวเองคนเดียวที่ใช้เซิร์ฟเวอร์นี้ การเปิดใช้งานโหมดนี้จะช่วยปรับการทำงานให้เหมาะสมที่สุด" + signToActivityPubGet: "ลงนามในคำขอ GET" + signToActivityPubGet_description: "โดยปกติควรเปิดใช้งาน แต่หากพบปัญหาเกี่ยวกับการสื่อสารในสหพันธ์ การปิดใช้งานอาจช่วยแก้ไขได้ แต่ในบางกรณี เซิร์ฟเวอร์อาจไม่สามารถสื่อสารได้เลยหากปิดใช้งานนี้" + proxyRemoteFiles: "พร็อกซีไฟล์ระยะไกล" + proxyRemoteFiles_description: "เมื่อเปิดใช้งาน จะทำหน้าที่เป็นพร็อกซีสำหรับไฟล์จากระยะไกล ช่วยในการสร้างภาพขนาดย่อและปกป้องความเป็นส่วนตัวของผู้ใช้" + allowExternalApRedirect: "อนุญาตการเปลี่ยนเส้นทางการสืบค้นผ่าน ActivityPub" + allowExternalApRedirect_description: "เมื่อเปิดใช้งาน จะอนุญาตให้เซิร์ฟเวอร์อื่นสืบค้นเนื้อหาของบุคคลที่สามผ่านเซิร์ฟเวอร์นี้ได้ แต่มีความเสี่ยงที่อาจเกิดการปลอมแปลงเนื้อหา" + userGeneratedContentsVisibilityForVisitor: "ขอบเขตการเปิดเผยเนื้อหาที่ผู้ใช้สร้างต่อบุคคลที่ไม่ได้เข้าร่วม (แขก)" + userGeneratedContentsVisibilityForVisitor_description: "ช่วยป้องกันปัญหาที่อาจเกิดขึ้นจากเนื้อหาระยะไกลที่ไม่เหมาะสม ซึ่งอาจถูกเผยแพร่ออกสู่อินเทอร์เน็ตโดยไม่ตั้งใจผ่านเซิร์ฟเวอร์ของตนเอง โดยเฉพาะในกรณีที่การดูแลควบคุมไม่ทั่วถึง" + userGeneratedContentsVisibilityForVisitor_description2: "การเปิดเผยเนื้อหาทั้งหมดในเซิร์ฟเวอร์รวมทั้งเนื้อหาที่รับมาจากระยะไกลสู่สาธารณะบนอินเทอร์เน็ตโดยไม่มีข้อจำกัดใดๆ มีความเสี่ยงโดยเฉพาะอย่างยิ่งสำหรับผู้ชมที่ไม่เข้าใจลักษณะของระบบแบบกระจาย อาจทำให้เกิดความเข้าใจผิดคิดว่าเนื้อหาที่มาจากระยะไกลนั้นเป็นเนื้อหาที่สร้างขึ้นภายในเซิร์ฟเวอร์นี้ จึงควรใช้ความระมัดระวังอย่างมาก" + _userGeneratedContentsVisibilityForVisitor: + all: "ทั้งหมดสาธารณะ" + localOnly: "เผยแพร่เป็นสาธารณะเฉพาะเนื้อหาท้องถิ่น เนื้อหาระยะไกลให้เป็นส่วนตัว" + none: "ทั้งหมดส่วนตัว" _accountMigration: moveFrom: "ย้ายจากบัญชีอื่นมาที่บัญชีนี้" moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" @@ -1750,13 +1943,15 @@ _role: baseRole: "แม่แบบบทบาท" useBaseValue: "ใช้ตามแม่แบบบทบาท" chooseRoleToAssign: "เลือกบทบาทที่ต้องการกำหนด" - iconUrl: "URL ไอคอน" + iconUrl: "URL ของไอคอน" asBadge: "แสดงเป็นตรา" - descriptionOfAsBadge: "เมื่อเปิดใช้งาน ไอคอนบทบาทจะปรากฏถัดจากชื่อผู้ใช้" + descriptionOfAsBadge: "หากเปิดใช้งาน จะมีไอคอนของบทบาท แสดงถัดจากชื่อผู้ใช้" isExplorable: "ค้นหาผู้ใช้ได้ง่ายขึ้นโดยดูจากบทบาท" descriptionOfIsExplorable: "เมื่อเปิดใช้งาน ไทมไลน์บทบาทนี้และสมาชิกที่มีบทบาทนี้จะเปิดเผยเป็นสาธารณะ" displayOrder: "ลำดับการแสดงผล" descriptionOfDisplayOrder: "เลขที่สูงกว่าจะแสดงบน UI ก่อน" + preserveAssignmentOnMoveAccount: "โอนสถานะการมอบหมายไปยังบัญชีที่ย้ายไป" + preserveAssignmentOnMoveAccount_description: "เมื่อเปิดใช้งาน บัญชีที่ได้รับบทบาทนี้เมื่อถูกย้ายไปบัญชีใหม่ บทบาทนี้จะถูกถ่ายทอดไปยังบัญชีปลายทางด้วย" canEditMembersByModerator: "อนุญาตให้ผู้ควบคุมแก้ไขสมาชิก" descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ นอกเหนือจากผู้ควบคุมและผู้ดูแลระบบแล้ว จะสามารถเพิ่มถอนบทบาทนี้แก่ผู้ใช้ได้ แต่เมื่อปิดใช้ จะมีเฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถดำเนินการได้" priority: "ลำดับความสำคัญ" @@ -1776,8 +1971,9 @@ _role: canManageCustomEmojis: "จัดการเอโมจิที่กำหนดเอง" canManageAvatarDecorations: "จัดการตกแต่งอวตาร" driveCapacity: "ความจุของไดรฟ์" + maxFileSize: "ขนาดไฟล์สูงสุดที่สามารถอัปโหลดได้" alwaysMarkNsfw: "ทำเครื่องหมายไฟล์ว่าเป็น NSFW เสมอ" - canUpdateBioMedia: "อนุญาตให้ปรับปรุงไอคอนและแบนเนอร์" + canUpdateBioMedia: "อนุญาตให้เปลี่ยนไอคอนประจำตัวและแบนเนอร์" pinMax: "จํานวนสูงสุดของโน้ตที่ปักหมุดไว้" antennaMax: "จำนวนสูงสุดของเสาอากาศ" wordMuteMax: "จำนวนอักขระสูงสุดที่อนุญาตในการปิดเสียงคำ" @@ -1791,12 +1987,18 @@ _role: canHideAds: "ซ่อนโฆษณา" canSearchNotes: "การใช้การค้นหาโน้ต" canUseTranslator: "การใช้งานแปล" - avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้" + avatarDecorationLimit: "จำนวนของตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้" canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ" canImportBlocking: "อนุญาตให้นำเข้าการบล็อก" canImportFollowing: "อนุญาตให้นำเข้ารายการต่อไปนี้" - canImportMuting: "อนุญาตให้นำเข้าการปิดกั้น" + canImportMuting: "อนุญาตให้นำเข้าการปิดเสียง" canImportUserLists: "อนุญาตให้นำเข้ารายการ" + chatAvailability: "อนุญาตให้แชต" + uploadableFileTypes: "ประเภทไฟล์ที่สามารถอัปโหลดได้" + uploadableFileTypes_caption: "สามารถระบุ MIME type ได้ โดยใช้การขึ้นบรรทัดใหม่เพื่อแยกหลายรายการ และสามารถใช้ดอกจัน (*) เพื่อระบุแบบไวลด์การ์ดได้ (เช่น: image/*)" + uploadableFileTypes_caption2: "ไฟล์บางประเภทอาจไม่สามารถระบุชนิดได้ หากต้องการอนุญาตไฟล์ลักษณะนั้น กรุณาเพิ่ม {x} ลงในรายการที่อนุญาต" + noteDraftLimit: "จำนวนโน้ตฉบับร่างที่สามารถสร้างได้บนฝั่งเซิร์ฟเวอร์" + watermarkAvailable: "มีฟังก์ชั่นลายน้ำให้เลือกใช้" _condition: roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ" isLocal: "ผู้ใช้ท้องถิ่น" @@ -1956,10 +2158,12 @@ _theme: install: "ติดตั้งธีม" manage: "จัดการธีม" code: "โค้ดธีม" - description: "รายละเอียด" + copyThemeCode: "คัดลอกรหัสธีม" + description: "คำอธิบาย" installed: "{name} ได้รับการติดตั้ง" installedThemes: "ธีมที่ติดตั้ง" builtinThemes: "ธีมในตัว" + instanceTheme: "ธีมของเซิร์ฟเวอร์" alreadyInstalled: "ธีมนี้ได้รับการติดตั้งแล้ว" invalid: "รูปแบบของธีมนี้ไม่ถูกต้องนะ" make: "ทำธีม" @@ -1987,7 +2191,7 @@ _theme: fg: "ข้อความ" focus: "โฟกัส" indicator: "ตัวบ่งชี้" - panel: "แผงควบคุม" + panel: "แผง" shadow: "เงา" header: "ส่วนหัว" navBg: "พื้นหลังแถบด้านข้าง" @@ -1997,7 +2201,7 @@ _theme: link: "ลิงก์" hashtag: "แฮชแท็ก" mention: "กล่าวถึง" - mentionMe: "ได้กล่าวถึง (ฉัน)" + mentionMe: "ได้กล่าวถึงคุณ" renote: "รีโน้ต" modalBg: "พื้นหลังโมดอล" divider: "ตัวแบ่ง" @@ -2021,6 +2225,7 @@ _sfx: noteMy: "โน้ตของตัวเอง" notification: "การเเจ้งเตือน" reaction: "เมื่อเลือกรีแอคชั่น" + chatMessage: "ข้อความของแชต" _soundSettings: driveFile: "ใช้เสียงจากไดรฟ์" driveFileWarn: "เลือกไฟล์ในไดรฟ์ของคุณ" @@ -2063,15 +2268,15 @@ _2fa: step3: "ป้อนโทเค็นที่แอปของคุณให้มาเพื่อเสร็จสิ้นการตั้งค่า" setupCompleted: "ตั้งค่าสำเร็จแล้ว" step4: "นับจากนี้เป็นต้นไปการพยายามเข้าสู่ระบบในอนาคตนั้น อาจจะต้องขอโทเค็นในการเข้าสู่ระบบดังกล่าว" - securityKeyNotSupported: "เบราว์เซอร์ของคุณไม่รองรับคีย์ความปลอดภัยนะ" - registerTOTPBeforeKey: "กรุณาตั้งค่าแอปยืนยันตัวตนเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" - securityKeyInfo: "นอกจากนี้การตรวจสอบความถูกต้องด้วยลายนิ้วมือหรือ PIN แล้ว คุณยังสามารถตั้งค่าการตรวจสอบสิทธิ์ผ่านคีย์ความปลอดภัยของฮาร์ดแวร์ที่รองรับ FIDO2 เพื่อเพิ่มความปลอดภัยให้กับบัญชีของคุณ" - registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" + securityKeyNotSupported: "เว็บเบราว์เซอร์ที่ใช้งานอยู่ไม่รองรับ Security Key" + registerTOTPBeforeKey: "ก่อนลงทะเบียน Security Key หรือ Passkey กรุณาตั้งค่าแอปยืนยันตัวตนก่อน" + securityKeyInfo: "ลงทะเบียนกุญแจที่มาจาก WebAuthn เช่น Security Key แบบฮาร์ดแวร์ที่รองรับ FIDO2 การยืนยันตัวตนด้วยชีวมิติหรือ PIN บนอุปกรณ์ และ Passkey" + registerSecurityKey: "ลงทะเบียน Security Key หรือ Passkey" securityKeyName: "ป้อนชื่อคีย์" - tapSecurityKey: "กรุณาทำตามเบราว์เซอร์ของคุณเพื่อลงทะเบียนรหัสความปลอดภัยหรือรหัสผ่าน" - removeKey: "ลบคีย์ความปลอดภัยออก" + tapSecurityKey: "กรุณาทำตามคำแนะนำของเบราว์เซอร์เพื่อลงทะเบียน Security Key หรือ Passkey" + removeKey: "ลบ Security Key ออก" removeKeyConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" - whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่มีการลงทะเบียนคีย์ความปลอดภัยไว้แล้ว" + whyTOTPOnlyRenew: "ไม่สามารถลบแอปตัวรับรองความถูกต้องได้ตราบใดที่ยังมีการลงทะเบียน Security Key อยู่" renewTOTP: "ตั้งค่าแอปยืนยันตัวตน" renewTOTPConfirm: "วิธีการแบบนี้จะทําให้รหัสยืนยันจากแอพก่อนหน้าของคุณหยุดทํางานเลยนะ" renewTOTPOk: "ตั้งค่าคอนฟิกใหม่" @@ -2168,6 +2373,7 @@ _permissions: "read:federation": "รับข้อมูลเกี่ยวกับสหพันธ์" "write:report-abuse": "รายงานการละเมิด" "write:chat": "เขียนหรือลบข้อความแชท" + "read:chat": "อ่านแชต" _auth: shareAccessTitle: "การให้สิทธิ์แอปพลิเคชัน" shareAccess: "คุณต้องการอนุญาตให้ \"{name}\" เข้าถึงบัญชีนี้เลยมั้ย?" @@ -2176,8 +2382,11 @@ _auth: permissionAsk: "แอปพลิเคชันนี้ขอสิทธิ์ดังต่อไปนี้" pleaseGoBack: "กรุณากลับไปที่แอปพลิเคชัน" callback: "กำลังกลับไปที่แอปพลิเคชัน" + accepted: "การเข้าถึงได้รับอนุญาต" denied: "ปฏิเสธการเข้าใช้" + scopeUser: "กำลังดำเนินการในฐานะผู้ใช้ต่อไปนี้" pleaseLogin: "กรุณาเข้าสู่ระบบเพื่ออนุมัติแอปพลิเคชัน" + byClickingYouWillBeRedirectedToThisUrl: "หากอนุญาตการเข้าถึง ระบบจะเปลี่ยนเส้นทางไปยัง URL ด้านล่างโดยอัตโนมัติ" _antennaSources: all: "โน้ตทั้งหมด" homeTimeline: "โน้ตจากผู้ใช้ที่ติดตาม" @@ -2223,6 +2432,7 @@ _widgets: chooseList: "เลือกรายชื่อ" clicker: "คลิกเกอร์" birthdayFollowings: "วันเกิดผู้ใช้ในวันนี้" + chat: "แชต" _cw: hide: "ซ่อน" show: "โหลดเพิ่มเติม" @@ -2262,6 +2472,8 @@ _visibility: disableFederation: "การปิดใช้งานสหพันธ์" disableFederationDescription: "อย่าส่งข้อมูลไปยังเซิร์ฟเวอร์อื่น" _postForm: + quitInspiteOfThereAreUnuploadedFilesConfirm: "มีไฟล์ที่ยังไม่ได้อัปโหลด ต้องการละทิ้งและปิดฟอร์มหรือไม่?" + uploaderTip: "ไฟล์ยังไม่ได้อัปโหลด สามารถตั้งค่าต่างๆ ได้จากเมนูของไฟล์ เช่น การเปลี่ยนชื่อ การครอปรูป การใส่ลายน้ำ และการบีบอัด ไฟล์จะถูกอัปโหลดโดยอัตโนมัติเมื่อโพสต์โน้ต" replyPlaceholder: "ตอบกลับโน้ตนี้..." quotePlaceholder: "อ้างโน้ตนี้..." channelPlaceholder: "โพสต์ลงช่อง..." @@ -2282,7 +2494,7 @@ _profile: metadataDescription: "ใช้สิ่งเหล่านี้ คุณสามารถแสดงฟิลด์ข้อมูลเพิ่มเติมในโปรไฟล์ของคุณ" metadataLabel: "ป้ายชื่อ" metadataContent: "เนื้อหา" - changeAvatar: "เปลี่ยนอวาตาร์" + changeAvatar: "เปลี่ยนไอคอนประจำตัว" changeBanner: "เปลี่ยนแบนเนอร์" verifiedLinkDescription: "หากป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณ ไอคอนการยืนยันความเป็นเจ้าของจะแสดงถัดจากฟิลด์นั้น ๆ" avatarDecorationMax: "คุณสามารถเพิ่มการตกแต่งได้สูงสุด {max}" @@ -2295,7 +2507,7 @@ _exportOrImport: clips: "คลิป" followingList: "กำลังติดตาม" muteList: "ปิดเสียง" - blockingList: "บล็อค" + blockingList: "บล็อก" userLists: "รายชื่อ" excludeMutingUsers: "ยกเว้นผู้ใช้ที่ปิดเสียง" excludeInactiveUsers: "ยกเว้นผู้ใช้ที่ไม่ได้ใช้งาน" @@ -2365,7 +2577,7 @@ _pages: featured: "เป็นที่นิยม" inspector: "ตัวตรวจสอบ" contents: "เนื้อหา" - content: "บล็อคหน้าเพจ" + content: "บล็อกหน้าเพจ" variables: "ตัวแปร" title: "หัวข้อ" url: "URL ของหน้า" @@ -2377,7 +2589,7 @@ _pages: fontSansSerif: "Sans Serif" eyeCatchingImageSet: "ตั้งค่าภาพขนาดย่อ" eyeCatchingImageRemove: "ลบภาพขนาดย่อ" - chooseBlock: "เพิ่มบล็อค" + chooseBlock: "เพิ่มบล็อก" enterSectionTitle: "ป้อนชื่อหัวข้อ" selectType: "เลือกชนิด" contentBlocks: "เนื้อหา" @@ -2413,6 +2625,7 @@ _notification: newNote: "โพสต์ใหม่" unreadAntennaNote: "เสาอากาศ {name}" roleAssigned: "ได้รับบทบาท" + chatRoomInvitationReceived: "ได้รับคำเชิญเข้าร่วมห้องแชต" emptyPushNotificationMessage: "อัปเดตการแจ้งเตือนแบบพุชแล้ว" achievementEarned: "รับความสำเร็จ" testNotification: "ทดสอบการแจ้งเตือน" @@ -2425,7 +2638,9 @@ _notification: followedBySomeUsers: "มีผู้ติดตาม {n} ราย" flushNotification: "ล้างประวัติการแจ้งเตือน" exportOfXCompleted: "การดำเนินการส่งออก {x} ได้เสร็จสิ้นลงแล้ว" - login: "มีคนล็อกอิน" + login: "มีการเข้าสู่ระบบ" + createToken: "สร้างโทเค็นการเข้าถึงแล้ว" + createTokenDescription: "หากไม่ทราบสาเหตุของคำเชิญ กรุณาลบโทเค็นการเข้าถึงผ่านทาง “{text}”" _types: all: "ทั้งหมด" note: "โน้ตใหม่" @@ -2439,9 +2654,11 @@ _notification: receiveFollowRequest: "ได้รับคำร้องขอติดตาม" followRequestAccepted: "อนุมัติให้ติดตามแล้ว" roleAssigned: "ให้บทบาท" + chatRoomInvitationReceived: "เชิญเข้าห้องแชต" achievementEarned: "ปลดล็อกความสำเร็จแล้ว" exportCompleted: "กระบวนการส่งออกข้อมูลได้เสร็จสิ้นสมบูรณ์แล้ว" login: "เข้าสู่ระบบ" + createToken: "สร้างโทเค็นการเข้าถึง" test: "ทดสอบระบบแจ้งเตือน" app: "การแจ้งเตือนจากแอปที่มีลิงก์" _actions: @@ -2451,6 +2668,9 @@ _notification: _deck: alwaysShowMainColumn: "แสดงคอลัมน์หลักเสมอ" columnAlign: "จัดแนวคอลัมน์" + columnGap: "ช่องห่างระว่างคอลัมน์" + deckMenuPosition: "ตำแหน่งเมนูเด็ค" + navbarPosition: "ตำแหน่งของแถบนำทาง" addColumn: "เพิ่มคอลัมน์" newNoteNotificationSettings: "ตั้งค่าการแจ้งเตือนเมื่อมีโน้ตใหม่" configureColumn: "ตั้งค่าคอลัมน์" @@ -2469,6 +2689,7 @@ _deck: useSimpleUiForNonRootPages: "แสดง UI ของ Root Page อย่างง่าย " usedAsMinWidthWhenFlexible: "ความกว้างขั้นต่ำนั้นจะถูกใช้งานสำหรับสิ่งนี้เมื่อเปิดใช้งานตัวเลือก \"ปรับความกว้างอัตโนมัติ\" หากเลือกเปิดใช้งานแล้ว" flexible: "ปรับความกว้างอัตโนมัติ" + enableSyncBetweenDevicesForProfiles: "เปิดใช้งานการซิงค์ข้อมูลโปรไฟล์ระหว่างอุปกรณ์" _columns: main: "หลัก" widgets: "วิดเจ็ต" @@ -2480,6 +2701,7 @@ _deck: mentions: "กล่าวถึงคุณ" direct: "ไดเร็กต์" roleTimeline: "บทบาทไทม์ไลน์" + chat: "แชต" _dialog: charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" @@ -2508,8 +2730,8 @@ _webhookSettings: abuseReport: "เมื่อมีการรายงานจากผู้ใช้" abuseReportResolved: "เมื่อมีการจัดการกับการรายงานจากผู้ใช้" userCreated: "เมื่อผู้ใช้ถูกสร้างขึ้น" - inactiveModeratorsWarning: "เมื่อผู้ดูแลระบบไม่ได้ใช้งานมานานระยะหนึ่ง" - inactiveModeratorsInvitationOnlyChanged: "เมื่อผู้ดูแลระบบที่ไม่ได้ใช้งานมานาน และเซิร์ฟเวอร์เปลี่ยนเป็นแบบเชิญเข้าร่วมเท่านั้น" + inactiveModeratorsWarning: "เมื่อผู้ควบคุมไม่มีความเคลื่อนไหวในช่วงระยะเวลาหนึ่ง" + inactiveModeratorsInvitationOnlyChanged: "เมื่อผู้ควบคุมไม่มีความเคลื่อนไหวในช่วงระยะเวลาหนึ่ง ระบบจะเปลี่ยนเป็นแบบใช้คำเชิญโดยอัตโนมัติ" deleteConfirm: "ต้องการลบ Webhook ใช่ไหม?" testRemarks: "คลิกปุ่มทางด้านขวาของสวิตช์เพื่อส่ง Webhook ทดสอบที่มีข้อมูลจำลอง" _abuseReport: @@ -2561,10 +2783,10 @@ _moderationLogTypes: createAd: "สร้างโฆษณาแล้ว" deleteAd: "ลบโฆษณาออกแล้ว" updateAd: "อัปเดตโฆษณาแล้ว" - createAvatarDecoration: "สร้างการตกแต่งไอคอนแล้ว" - updateAvatarDecoration: "อัปเดตการตกแต่งไอคอนแล้ว" - deleteAvatarDecoration: "ลบการตกแต่งไอคอนแล้ว" - unsetUserAvatar: "ลบไอคอนผู้ใช้" + createAvatarDecoration: "สร้างของตกแต่งไอคอนแล้ว" + updateAvatarDecoration: "อัปเดตของตกแต่งไอคอนแล้ว" + deleteAvatarDecoration: "ลบของตกแต่งไอคอนแล้ว" + unsetUserAvatar: "เลิกตั้งไอคอนประจำตัวแล้ว" unsetUserBanner: "ลบแบนเนอร์ผู้ใช้" createSystemWebhook: "สร้าง SystemWebhook" updateSystemWebhook: "อัปเดต SystemWebhook" @@ -2576,6 +2798,8 @@ _moderationLogTypes: deletePage: "เพจถูกลบออกไปแล้ว" deleteFlash: "Play ถูกลบออกไปแล้ว" deleteGalleryPost: "โพสต์แกลเลอรี่ถูกลบออกแล้ว" + deleteChatRoom: "ลบห้องแชต" + updateProxyAccountDescription: "อัปเดตคำอธิบายของบัญชีพร็อกซี" _fileViewer: title: "รายละเอียดไฟล์" type: "ประเภทไฟล์" @@ -2583,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "วันที่เข้าร่วม" attachedNotes: "โน้ตที่แนบมาด้วย" + usage: "ใช้แล้ว" thisPageCanBeSeenFromTheAuthor: "หน้าเพจนี้จะสามารถปรากฏได้โดยผู้ใช้ที่อัปโหลดไฟล์นี้เท่านั้น" _externalResourceInstaller: title: "ติดตั้งจากไซต์ภายนอก" @@ -2628,8 +2853,14 @@ _dataSaver: title: "โหลดสื่อ" description: "กันไม่ให้ภาพและวิดีโอโหลดโดยอัตโนมัติ แตะรูปภาพ/วิดีโอที่ซ่อนอยู่เพื่อโหลด" _avatar: - title: "รูปไอคอน" - description: "ระงับการเคลื่อนไหวของภาพไอคอน ภาพเคลื่อนไหวอาจมีขนาดไฟล์ใหญ่กว่าภาพปกติ ดังนั้นจึงสามารถช่วยในการลดการใช้ข้อมูล" + title: "ปิดใช้งานภาพเคลื่อนไหวของไอคอนประจำตัว" + description: "ภาพเคลื่อนไหวของไอคอนประจำตัวจะหยุดทำงาน ภาพแบบเคลื่อนไหวมักมีขนาดไฟล์ใหญ่กว่าภาพปกติ จึงช่วยลดปริมาณการใช้ข้อมูลได้มากขึ้น" + _urlPreviewThumbnail: + title: "ซ่อนภาพขนาดย่อของการแสดงตัวอย่าง URL" + description: "ภาพขนาดย่อของการตัวอย่าง URL จะไม่ถูกโหลดอีกต่อไป" + _disableUrlPreview: + title: "ปิดการใช้งานแสดงตัวอย่าง URL" + description: "ปิดฟังก์ชันแสดงตัวอย่าง URL แตกต่างจากการซ่อนเพียงภาพขนาดย่อ ฟังก์ชันนี้จะช่วยลดการโหลดข้อมูลจากลิงก์ปลายทางทั้งหมด" _code: title: "ไฮไลต์โค้ด" description: "หากใช้สัญลักษณ์ไฮไลต์โค้ดใน MFM ฯลฯ สัญลักษณ์เหล่านั้นจะไม่โหลดจนกว่าจะแตะ การไฮไลต์ไวยากรณ์(syntax)จำเป็นต้องดาวน์โหลดไฟล์คำจำกัดความของไฮไลต์สำหรับแต่ละภาษา ดังนั้นการปิดใช้งานการโหลดไฟล์เหล่านี้โดยอัตโนมัติจึงคาดว่าจะช่วยลดปริมาณข้อมูลการสื่อสารได้" @@ -2680,13 +2911,15 @@ _reversi: allowIrregularRules: "อนุญาตกฎที่ไม่ปรกติ (โหมดฟรีทุกอย่าง)" disallowIrregularRules: "ไม่อนุญาตกฎที่ไม่ปรกติ" showBoardLabels: "แสดงหมายเลขแถว/คอลัมน์บนกระดาน" - useAvatarAsStone: "ใช้รูปอวตารเป็นหมาก" + useAvatarAsStone: "ใช้ไอคอนประจำตัวเป็นหมาก" _offlineScreen: title: "ออฟไลน์ - ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้" header: "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้" _urlPreviewSetting: title: "การตั้งค่าการแสดงตัวอย่าง URL" enable: "เปิดใช้งานการแสดงตัวอย่าง URL" + allowRedirect: "อนุญาตการเปลี่ยนเส้นทางไปยังปลายทางของการแสดงตัวอย่าง" + allowRedirectDescription: "ตั้งค่าว่าจะติดตามลิงก์ที่เปลี่ยนเส้นทาง (redirect) เพื่อแสดงตัวอย่างหรือไม่ เมื่อมีการป้อน URL ที่มีการเปลี่ยนเส้นทาง หากปิดการใช้งาน จะช่วยประหยัดทรัพยากรของเซิร์ฟเวอร์ แต่จะไม่สามารถแสดงเนื้อหาจากปลายทางที่เปลี่ยนเส้นทางได้" timeout: "เวลาจำกัดในการโหลดตัวอย่าง URL (ms)" timeoutDescription: "หากเวลาที่ใช้ในการโหลดเกินค่านี้ จะไม่มีการสร้างการแสดงตัวอย่าง" maximumContentLength: "ค่าสูงสุดของ Content-Length (byte)" @@ -2707,6 +2940,62 @@ _contextMenu: app: "แอปพลิเคชัน" appWithShift: "แอปฟลิเคชันด้วยปุ่มยกแคร่ (Shift)" native: "UI ของเบราว์เซอร์" +_gridComponent: + _error: + requiredValue: "ค่านี้จำเป็นต้องกรอก" + columnTypeNotSupport: "การตรวจสอบค่าด้วย regex รองรับเฉพาะคอลัมน์ที่เป็น type:text" + patternNotMatch: "ค่านี้ไม่ตรงกับรูปแบบ {pattern}" + notUnique: "ค่านี้ต้องไม่ซ้ำกับค่าที่มีอยู่" +_roleSelectDialog: + notSelected: "ยังไม่มีการเลือก" +_customEmojisManager: + _gridCommon: + copySelectionRows: "คัดลอกแถวที่เลือกไว้" + copySelectionRanges: "คัดลือกที่เลือกไว้" + deleteSelectionRows: "ลบแถวที่เลือกไว้" + deleteSelectionRanges: "ล้างค่าช่วงที่เลือก" + searchSettings: "ตั้งค่าการค้นหา" + searchSettingCaption: "ตั้งค่าเงื่อนไขการค้นหาอย่างละเอียด" + searchLimit: "จำนวนรายการที่แสดง" + sortOrder: "ลำดับการเรียง" + registrationLogs: "ปูมการลงทะเบียน" + registrationLogsCaption: "จะแสดงปูมเมื่อมีการอัปเดตหรือลบเอโมจิ หากดำเนินการอัปเดต/ลบ หรือเปลี่ยนหน้า/รีโหลด หน้านี้ ปูมจะหายไป" + alertEmojisRegisterFailedDescription: "การอัปเดตหรือลบเอโมจิล้มเหลว กรุณาตรวจสอบรายละเอียดในปูมการลงทะเบียน" + _logs: + showSuccessLogSwitch: "แสดงปูมที่สำเร็จ" + failureLogNothing: "ไม่มีปูมความล้มเหลว" + logNothing: "ไม่มีปูม" + _remote: + selectionRowDetail: "รายละเอียดของแถวที่เลือก" + importSelectionRows: "นำเข้าแถวที่เลือก" + importSelectionRangesRows: "นำเข้าแถวในช่วงที่เลือก" + importEmojisButton: "นำเข้าเอโมจิที่ทำเครื่องหมายไว้" + confirmImportEmojisTitle: "นำเข้าเอโมจิ" + confirmImportEmojisDescription: "จะนำเข้าเอโมจิ {count} รายการที่ได้รับจากระยะไกล ทั้งนี้โปรดระมัดระวังเรื่องสิทธิ์การใช้งานเอโมจิ ดำเนินการหรือไม่?" + _local: + tabTitleList: "รายการเอโมจิที่ลงทะเบียนไว้แล้ว" + tabTitleRegister: "ลงทะเบียนเอโมจิ" + _list: + emojisNothing: "ยังไม่มีเอโมจิที่ลงทะเบียนไว้" + markAsDeleteTargetRows: "กำหนดแถวที่เลือกให้เป็นรายการสำหรับลบ" + markAsDeleteTargetRanges: "กำหนดช่วงแถวที่เลือกให้เป็นรายการสำหรับลบ" + alertUpdateEmojisNothingDescription: "ไม่มีการเปลี่ยนแปลงเอโมจิ" + alertDeleteEmojisNothingDescription: "ไม่มีเอโมจิที่อยู่ในรายการสำหรับลบ" + confirmMovePage: "ต้องการเปลี่ยนหน้าหรือไม่?" + confirmChangeView: "ต้องการเปลี่ยนการแสดงผลหรือไม่?" + confirmUpdateEmojisDescription: "จะอัปเดตเอโมจิ {count} รายการ ดำเนินการหรือไม่?" + confirmDeleteEmojisDescription: "จะลบเอโมจิที่ถูกทำเครื่องหมายไว้ {count} รายการ ดำเนินการหรือไม่?" + confirmResetDescription: "การเปลี่ยนแปลงทั้งหมดที่ทำมาจะถูกรีเซ็ต" + confirmMovePageDesciption: "มีการเปลี่ยนแปลงเอโมจิในหน้านี้ หากเปลี่ยนหน้าโดยไม่บันทึก การเปลี่ยนแปลงทั้งหมดจะถูกละทิ้ง" + dialogSelectRoleTitle: "ค้นหาบทบาทที่ตั้งค่าไว้ด้วยเอโมจิ" + _register: + uploadSettingTitle: "ตั้งค่าการอัปโหลด" + uploadSettingDescription: "สามารถกำหนดพฤติกรรมขณะอัปโหลดเอโมจิจากหน้าจอนี้ได้" + directoryToCategoryLabel: "ป้อนชื่อไดเรกทอรีเป็น \"category\"" + directoryToCategoryCaption: "เมื่อทำการลากและวางไดเรกทอรี ชื่อจะถูกป้อนเป็น \"category\"" + confirmRegisterEmojisDescription: "จะลงทะเบียนเอโมจิที่แสดงในรายการเป็นเอโมจิแบบกำหนดเองใหม่\nดำเนินการต่อหรือไม่? (เพื่อหลีกเลี่ยงภาระโหลดหนัก ระบบจะสามารถลงทะเบียนอีโมจิได้สูงสุด {count} รายการต่อครั้ง)" + confirmClearEmojisDescription: "ต้องการยกเลิกการแก้ไขและล้างรายการเอโมจิที่แสดงอยู่หรือไม่?" + confirmUploadEmojisDescription: "จะอัปโหลดไฟล์ {count} รายการที่ลากและวางไปยังไดรฟ์ ดำเนินการหรือไม่?" _embedCodeGen: title: "ปรับแต่งโค้ดฝัง" header: "แสดงส่วนหัว" @@ -2721,15 +3010,137 @@ _embedCodeGen: generateCode: "สร้างโค้ดสำหรับการฝัง" codeGenerated: "รหัสถูกสร้างขึ้นแล้ว" codeGeneratedDescription: "นำโค้ดที่สร้างแล้วไปวางในเว็บไซต์ของคุณเพื่อฝังเนื้อหา" +_selfXssPrevention: + warning: "คำเตือน" + title: "“ข้อความที่บอกให้วางบางอย่างในหน้าจอนี้” ทั้งหมดเป็นการหลอกลวง" + description1: "ถ้าวางบางอย่างที่นี่ อาจทำให้ผู้ไม่หวังดีเข้าควบคุมบัญชี หรือขโมยข้อมูลส่วนตัวได้" + description2: "ถ้าไม่เข้าใจอย่างชัดเจนว่าสิ่งที่กำลังจะวางคืออะไร %cให้หยุดการทำงานทันทีแล้วปิดหน้าต่างนี้" + description3: "ดูรายละเอียดเพิ่มเติมได้ที่นี่: {link}" +_followRequest: + recieved: "คำขอที่ได้รับ" + sent: "คำที่ส่งไป" _remoteLookupErrors: + _federationNotAllowed: + title: "ไม่สามารถสื่อสารกับเซิร์ฟเวอร์นี้ได้" + description: "การสื่อสารกับเซิร์ฟเวอร์นี้อาจถูกปิดใช้งาน หรือเซิร์ฟเวอร์นี้อาจจะได้บล็อกคุณ หรือคุณอาจจะได้บล็อกเซิร์ฟเวอร์นี้อยู่\nกรุณาติดต่อผู้ดูแลระบบเซิร์ฟเวอร์เพื่อสอบถามรายละเอียดเพิ่มเติม" + _uriInvalid: + title: "URI ไม่ถูกต้อง" + description: "มีปัญหาเกี่ยวกับ URI ที่ป้อน โปรดตรวจสอบว่าไม่มีอักขระที่ไม่สามารถใช้กับ URI" + _requestFailed: + title: "การร้องขอล้มเหลว" + description: "การสื่อสารกับเซิร์ฟเวอร์นี้ล้มเหลว เซิร์ฟเวอร์ปลายทางอาจล่ม หรืออาจป้อน URI ที่ไม่ถูกต้องหรือไม่มีอยู่" + _responseInvalid: + title: "ข้อมูลตอบสนองกลับไม่ถูกต้อง" + description: "สามารถเชื่อมต่อกับเซิร์ฟเวอร์นี้ได้ แต่ข้อมูลที่ได้รับไม่ถูกต้อง หากกำลังดึงข้อมูลจากเซิร์ฟเวอร์บุคคลที่สาม โปรดใช้ URI ที่สามารถดึงข้อมูลได้จากเซิร์ฟเวอร์ต้นทางโดยตรง" _noSuchObject: title: "ไม่พบหน้าที่ต้องการ" + description: "ไม่พบทรัพยากรที่ร้องขอ กรุณาตรวจสอบ URI อีกครั้ง" +_captcha: + verify: "กรุณาผ่าน CAPTCHA" + testSiteKeyMessage: "สามารถดูตัวอย่างได้โดยป้อนค่าทดสอบใน site key และ secret key\nดูรายละเอียดเพิ่มเติมได้ที่หน้าด้านล่างนี้" + _error: + _requestFailed: + title: "การร้องขอ CAPTCHA ล้มเหลว" + text: "โปรดลองใหม่ภายหลัง หรือ ตรวจสอบการตั้งค่าอีกครั้ง" + _verificationFailed: + title: "การยืนยัน CAPTCHA ล้มเหลว" + text: "กรุณาตรวจสอบอีกครั้งว่าการตั้งค่าถูกต้องหรือไม่" + _unknown: + title: "CAPTCHA เกิดข้อผิดพลาด" + text: "เกิดข้อผิดพลาดที่ไม่คาดคิด" +_bootErrors: + title: "การโหลดล้มเหลว" + serverError: "หากปัญหายังคงอยู่แม้ว่าจะรอสักครู่แล้วโหลดหน้าใหม่อีกครั้ง โปรดติดต่อผู้ดูแลระบบเซิร์ฟเวอร์พร้อมรหัสข้อผิดพลาดต่อไปนี้" + solution: "สิ่งต่อไปนี้อาจช่วยแก้ไขปัญหาได้" + solution1: "อัปเดตเบราว์เซอร์และระบบปฏิบัติการเป็นรุ่นล่าสุด" + solution2: "ปิดใช้งานตัวบล็อกโฆษณา" + solution3: "ล้างแคชเบราว์เซอร์" + solution4: "(Tor Browser) ตั้งค่า dom.webaudio.enabled เป็น true" + otherOption: "ตัวเลือกเพิ่มเติม" + otherOption1: "ลบการตั้งค่าและแคชของไคลเอนต์" + otherOption2: "เริ่มใช้งานไคลเอนต์แบบง่าย" + otherOption3: "เปิดเครื่องมือซ่อมแซม" _search: searchScopeAll: "ทั้งหมด" searchScopeLocal: "ท้องถิ่น" + searchScopeServer: "ระบุเซิร์ฟเวอร์" searchScopeUser: "ผู้ใช้เฉพาะ" + pleaseEnterServerHost: "กรุณากรอกโฮสต์ของเซิร์ฟเวอร์" + pleaseSelectUser: "กรุณาเลือกผู้ใช้" + serverHostPlaceholder: "ตัวอย่าง: misskey.example.com" +_serverSetupWizard: + installCompleted: "การติดตั้ง Misskey เสร็จสมบูรณ์แล้ว!" + firstCreateAccount: "ขั้นแรก ให้สร้างบัญชีผู้ดูแลระบบ" + accountCreated: "บัญชีผู้ดูแลระบบถูกสร้างขึ้นแล้ว!" + serverSetting: "การตั้งค่าเซิร์ฟเวอร์" + youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "สามารถตั้งค่าเซิร์ฟเวอร์ได้อย่างง่ายดายด้วยวิซาร์ดนี้" + settingsYouMakeHereCanBeChangedLater: "สามารถเปลี่ยนแปลงการตั้งค่าเหล่านี้ในภายหลังได้" + howWillYouUseMisskey: "ต้องการใช้ Misskey อย่างไร?" + _use: + single: "เซิร์ฟเวอร์คนเดียว" + single_description: "ใช้งานเป็นเซิร์ฟเวอร์ส่วนตัวสำหรับตัวเองคนเดียว" + single_youCanCreateMultipleAccounts: "แม้จะใช้งานเป็นเซิร์ฟเวอร์ส่วนตัวสำหรับคนเดียว ก็สามารถสร้างบัญชีผู้ใช้หลายบัญชีได้ตามความจำเป็น" + group: "เซิร์ฟเวอร์กลุ่ม" + group_description: "เชิญผู้ใช้ที่เชื่อถือได้ มาเข้าร่วมใช้งานแบบหลายคน" + open: "เซิร์ฟเวอร์สาธารณะ" + open_description: "เปิดรับผู้ใช้จำนวนมากแบบไม่จำกัด" + openServerAdvice: "การเปิดรับผู้ใช้จำนวนมากมีความเสี่ยง ควรบริหารจัดการด้วยระบบดูแลที่เข้มงวดเพื่อรับมือกับปัญหาที่อาจเกิดขึ้น" + openServerAntiSpamAdvice: "เพื่อป้องกันไม่ให้เซิร์ฟเวอร์ของตนกลายเป็นแหล่งส่งสแปม ควรเปิดใช้งานฟีเจอร์ป้องกันบอต เช่น reCAPTCHA และใส่ใจเรื่องความปลอดภัยอย่างเคร่งครัด" + howManyUsersDoYouExpect: "คาดว่าจะมีผู้ใช้งานประมาณกี่คน?" + _scale: + small: "น้อยกว่า 100 คน (ขนาดเล็ก)" + medium: "เกิน 100 คน แต่น้อยกว่า 1000 คน (ขนาดกลาง)" + large: "เกิน 1000 คน (ขนาดใหญ่)" + largeScaleServerAdvice: "เซิร์ฟเวอร์ขนาดใหญ่อาจต้องการความรู้ด้านโครงสร้างพื้นฐานขั้นสูง เช่น การบาลานซ์โหลด หรือการทำสำเนาฐานข้อมูล" + doYouConnectToFediverse: "เชื่อมต่อกับ Fediverse หรือไม่?" + doYouConnectToFediverse_description1: "หากเชื่อมต่อกับเครือข่ายที่ประกอบด้วยเซิร์ฟเวอร์แบบกระจาย (Fediverse) จะสามารถแลกเปลี่ยนเนื้อหากับเซิร์ฟเวอร์อื่นๆ ได้" + doYouConnectToFediverse_description2: "การเชื่อมต่อกับ Fediverse เรียกว่า “สหพันธ์”" + youCanConfigureMoreFederationSettingsLater: "หลังจากนี้ยังสามารถตั้งค่าแบบขั้นสูง เช่น การกำหนดเซิร์ฟเวอร์ที่อนุญาตให้สหพันธ์ต่อกันได้เพิ่มเติม" + adminInfo: "ข้อมูลผู้ดูแลระบ" + adminInfo_description: "ตั้งค่าข้อมูลผู้ดูแลระบบที่จะใช้รับคำถามและติดต่อ" + adminInfo_mustBeFilled: "หากเปิดใช้เซิร์ฟเวอร์สาธารณะ หรือเปิดใช้งานสหพันธ์ จะต้องกรอกข้อมูลนี้" + followingSettingsAreRecommended: "แนะนำให้ตั้งค่าตามด้านล่างนี้" + applyTheseSettings: "ใช้การตั้งค่านี้" + skipSettings: "ข้ามการตั้งค่า" + settingsCompleted: "การตั้งค่าเสร็จสมบูรณ์แล้ว!" + settingsCompleted_description: "ขอบคุณที่สละเวลามาตั้งค่า ตอนนี้เซิร์ฟเวอร์พร้อมใช้งานได้ทันที" + settingsCompleted_description2: "การตั้งค่าเซิร์ฟเวอร์อย่างละเอียดสามารถทำได้จาก “แผงควบคุม”" + donationRequest: "คำขอรับบริจาค" + _donationRequest: + text1: "Misskey เป็นซอฟต์แวร์ฟรีที่พัฒนาโดยอาสาสมัคร" + text2: "เพื่อให้การพัฒนางานนี้สามารถดำเนินต่อไปได้ในอนาคต หากไม่เป็นการรบกวน รบกวนพิจารณาร่วมสมทบทุนด้วยนะคะ" + text3: "นอกจากนี้ยังมีสิทธิพิเศษสำหรับผู้สนับสนุนอีกด้วยค่ะ" +_uploader: + editImage: "แก้ไขรูปภาพ" + compressedToX: "บีบอัดเป็น {x}" + savedXPercent: "ประหยัดไป {x}%" + abortConfirm: "มีไฟล์ที่ยังไม่ได้อัปโหลด ต้องการยกเลิกหรือไม่?" + doneConfirm: "มีไฟล์ที่ยังไม่ได้อัปโหลด ต้องการดำเนินการให้เสร็จสิ้นหรือไม่?" + maxFileSizeIsX: "ขนาดไฟล์สูงสุดที่สามารถอัปโหลดได้คือ {x}" + allowedTypes: "ประเภทไฟล์ที่สามารถอัปโหลดได้" + tip: "ยังไม่มีไฟล์ถูกอัปโหลด สามารถ ตรวจสอบ ลบชื่อไฟล์ บีบอัด หรือครอปตัดภาพ ก่อนอัปโหลดได้ในหน้านี้ เมื่อพร้อมแล้วให้กดปุ่ม “อัปโหลด” เพื่อเริ่มการอัปโหลด" +_clientPerformanceIssueTip: + title: "หากรู้สึกว่าแบตเตอรี่หมดเร็ว" + makeSureDisabledAdBlocker: "โปรดปิดการใช้งานตัวบล็อกโฆษณา" + makeSureDisabledAdBlocker_description: "ตัวบล็อกโฆษณาอาจส่งผลต่อประสิทธิภาพ โปรดตรวจสอบว่าไม่ได้เปิดใช้งานผ่านฟังก์ชันของระบบปฏิบัติการ เบราว์เซอร์ หรือส่วนเสริมใดๆ" + makeSureDisabledCustomCss: "โปรดปิดการใช้งาน CSS แบบกำหนดเอง" + makeSureDisabledCustomCss_description: "การเขียนทับสไตล์อาจส่งผลต่อประสิทธิภาพ โปรดตรวจสอบว่าไม่มี CSS แบบกำหนดเองหรือส่วนเสริมที่แก้ไขสไตล์เปิดใช้งานอยู่" + makeSureDisabledAddons: "โปรดปิดการใช้งานส่วนเสริม" + makeSureDisabledAddons_description: "ส่วนเสริมบางตัวอาจรบกวนการทำงานของไคลเอนต์และทำให้ประสิทธิภาพลดลง กรุณาลองปิดส่วนเสริมในเบราว์เซอร์แล้วตรวจสอบอีกครั้ง" +_clip: + tip: "คลิปเป็นฟังก์ชันที่สามารถรวมโน้ตเข้าด้วยกัน" +_userLists: + tip: "สามารถสร้างรายชื่อที่มีผู้ใช้ใดก็ได้ เมื่อสร้างแล้ว รายชื่อนั้นจะแสดงเป็นไทม์ไลน์ได้" +watermark: "ลายน้ำ" +defaultPreset: "พรีเซ็ตเริ่มต้น" _watermarkEditor: + tip: "สามารถเพิ่มลายน้ำ เช่น ข้อมูลเครดิต ลงในภาพได้" + quitWithoutSaveConfirm: "ต้องการออกโดยไม่บันทึกหรือไม่?" driveFileTypeWarn: "ไม่รองรับไฟล์นี้" + driveFileTypeWarnDescription: "กรุณาเลือกไฟล์ภาพ" + title: "แก้ไขลายน้ำ" + cover: "ซ้อนทับทั่วทั้งพื้นที่" + repeat: "ปูให้เต็มพื้นที่" opacity: "ความทึบแสง" scale: "ขนาด" text: "ข้อความ" @@ -2737,4 +3148,50 @@ _watermarkEditor: type: "รูปแบบ" image: "รูปภาพ" advanced: "ขั้นสูง" + stripe: "ริ้ว" + stripeWidth: "ความกว้างเส้น" + stripeFrequency: "จำนวนเส้น" angle: "แองเกิล" + polkadot: "ลายจุด" + checker: "ช่องตาราง" + polkadotMainDotOpacity: "ความทึบของจุดหลัก" + polkadotMainDotRadius: "ขนาดของจุดหลัก" + polkadotSubDotOpacity: "ความทึบของจุดรอง" + polkadotSubDotRadius: "ขนาดของจุดรอง" + polkadotSubDotDivisions: "จำนวนจุดรอง" +_imageEffector: + title: "เอฟเฟกต์" + addEffect: "เพิ่มเอฟเฟกต์" + discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?" + _fxs: + chromaticAberration: "ความคลาดสี" + glitch: "กลิตช์" + mirror: "กระจก" + invert: "กลับสี" + grayscale: "ขาวดำเทา" + colorAdjust: "ปรับแก้สี" + colorClamp: "บีบอัดสี" + colorClampAdvanced: "บีบอัดสี (ขั้นสูง)" + distort: "บิดเบี้ยว" + threshold: "สองสี" + zoomLines: "เส้นความเข้มข้น" + stripe: "ริ้ว" + polkadot: "ลายจุด" + checker: "ช่องตาราง" + blockNoise: "บล็อกที่มีการรบกวน" + tearing: "ฉีกขาด" +drafts: "ร่าง" +_drafts: + select: "เลือกฉบับร่าง" + cannotCreateDraftAnymore: "ถึงจำนวนจำกัดที่ฉบับร่างที่สามารถสร้างได้แล้ว" + cannotCreateDraft: "ไม่สามารถสร้างฉบับร่างด้วยเนื้อหานี้ได้" + delete: "ลบฉบับร่าง" + deleteAreYouSure: "ต้องการลบฉบับร่างหรือไม่?" + noDrafts: "ไม่มีฉบับร่าง" + replyTo: "ตอบกลับ {user}" + quoteOf: "อ้างอิงถึงโน้ตของ {user}" + postTo: "โพสต์ไปยัง {channel}" + saveToDraft: "บันทึกเป็นฉบับร่าง" + restoreFromDraft: "คืนค่าจากฉบับร่าง" + restore: "กู้คืน" + listDrafts: "รายการฉบับร่าง" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 0c7308a016..c6708ec0e2 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -8,6 +8,9 @@ search: "Пошук" notifications: "Сповіщення" username: "Ім'я користувача" password: "Пароль" +initialPasswordForSetup: "Початковий пароль для налаштування" +initialPasswordIsIncorrect: "Початковий пароль для налаштування неправильний" +initialPasswordForSetupDescription: "Використайте пароль, вказаний у конфігураційному файлі, якщо ви встановлювали Misskey власноруч.\nЯкщо використовуєте сервіси хостингу Misskey, використайте наданий пароль.\nЯкщо ви не маєте паролю, лишіть порожнім щоб продовжити. " forgotPassword: "Я забув пароль" fetchingAsApObject: "Отримуємо з федіверсу..." ok: "OK" @@ -45,6 +48,7 @@ pin: "Закріпити" unpin: "Відкріпити" copyContent: "Скопіювати контент" copyLink: "Скопіювати посилання" +copyRemoteLink: "Копіювати віддалене посилання" delete: "Видалити" deleteAndEdit: "Видалити й редагувати" deleteAndEditConfirm: "Ви впевнені, що хочете видалити цю нотатку та відредагувати її? Ви втратите всі реакції, поширення та відповіді на неї." @@ -57,6 +61,7 @@ copyUserId: "Копіювати ID користувача" copyNoteId: "блокнот ID користувача" copyFileId: "Скопіювати ідентифікатор файлу." searchUser: "Пошук користувачів" +searchThisUsersNotes: "Пошук нотаток користувача" reply: "Відповісти" loadMore: "Показати більше" showMore: "Показати більше" @@ -105,9 +110,11 @@ enterEmoji: "Введіть емодзі" renote: "Поширити" unrenote: "Відміна поширення" renoted: "Поширити запис." +renotedToX: "Поширено до {name}" cantRenote: "Неможливо поширити." cantReRenote: "Поширення не можливо поширити." quote: "Цитата" +inChannelRenote: "Поширено у канал" pinnedNote: "Закріплений запис" pinned: "Закріпити" you: "Ви" @@ -116,6 +123,7 @@ sensitive: "NSFW" add: "Додати" reaction: "Реакції" reactions: "Реакції" +emojiPicker: "Вибір реакції" reactionSettingDescription2: "Перемістити щоб змінити порядок, Клацнути мишою щоб видалити, Натиснути \"+\" щоб додати." rememberNoteVisibility: "Пам’ятати параметри видимісті" attachCancel: "Видалити вкладення" @@ -289,7 +297,9 @@ folderName: "Ім'я теки" createFolder: "Створити теку" renameFolder: "Перейменувати теку" deleteFolder: "Видалити теку" +folder: "Тека" addFile: "Додати файл" +showFile: "Показати файл" emptyDrive: "Диск порожній" emptyFolder: "Тека порожня" unableToDelete: "Видалення неможливе" @@ -302,6 +312,7 @@ copyUrl: "Копіювати URL" rename: "Перейменувати" avatar: "Аватар" banner: "Банер" +displayOfSensitiveMedia: "Показ чутливого медіа" whenServerDisconnected: "Коли зв’язок із сервером втрачено" disconnectedFromServer: "Зв’язок із сервером було перервано" reload: "Оновити" @@ -348,8 +359,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "Увімкнути hCaptcha" hcaptchaSiteKey: "Ключ сайту" hcaptchaSecretKey: "Секретний ключ" +mcaptcha: "MCaptcha" +enableMcaptcha: "Увімкнути MCaptcha" mcaptchaSiteKey: "Ключ сайту" mcaptchaSecretKey: "Секретний ключ" +mcaptchaInstanceUrl: "Посилання на сервер MCaptcha" recaptcha: "reCAPTCHA" enableRecaptcha: "Увімкнути reCAPTCHA" recaptchaSiteKey: "Ключ сайту" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 6936d16799..0958158e5e 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1313,6 +1313,7 @@ availableRoles: "可用角色" acknowledgeNotesAndEnable: "理解注意事项后再开启。" federationSpecified: "此服务器已开启联合白名单。只能与管理员指定的服务器通信。" federationDisabled: "此服务器已禁用联合。无法与其它服务器上的用户通信。" +draft: "草稿" confirmOnReact: "发送回应前需要确认" reactAreYouSure: "要用「{emoji}」进行回应吗?" markAsSensitiveConfirm: "要将此媒体标记为敏感吗?" @@ -1367,6 +1368,9 @@ redisplayAllTips: "重新显示所有的提示和技巧" hideAllTips: "隐藏所有的提示和技巧" defaultImageCompressionLevel: "默认图像压缩等级" defaultImageCompressionLevel_description: "较低的等级可以保持画质,但会增加文件大小。
较高的等级可以减少文件大小,但相对应的画质将会降低。" +_order: + newest: "从新到旧" + oldest: "从旧到新" _chat: noMessagesYet: "还没有消息" newMessage: "新消息" @@ -1993,6 +1997,8 @@ _role: uploadableFileTypes: "可上传的文件类型" uploadableFileTypes_caption: "指定 MIME 类型。可用换行指定多个类型,也可以用星号(*)作为通配符。(如 image/*)" uploadableFileTypes_caption2: "文件根据文件的不同,可能无法判断其类型。若要允许此类文件,请在指定中添加 {x}。" + noteDraftLimit: "可在服务器上创建多少草稿" + watermarkAvailable: "能否使用水印功能" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -2152,6 +2158,7 @@ _theme: install: "安装主题" manage: "主题管理" code: "主题代码" + copyThemeCode: "复制主题代码" description: "描述" installed: "{name} 已安装" installedThemes: "已安装的主题" @@ -2800,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "添加日期" attachedNotes: "附加到的帖子" + usage: "使用" thisPageCanBeSeenFromTheAuthor: "此页只能被该文件的上传者查看。" _externalResourceInstaller: title: "从外部站点安装" @@ -3103,6 +3111,7 @@ _serverSetupWizard: text2: "为了今后也能继续开发,如果可以的话,请考虑一下捐助。" text3: "也有面向支援者的特典!" _uploader: + editImage: "编辑图像" compressedToX: "压缩 {x}" savedXPercent: "节省了 {x}% 的空间" abortConfirm: "还有未上传的文件,要中止吗?" @@ -3171,3 +3180,18 @@ _imageEffector: checker: "检查" blockNoise: "块状噪点" tearing: "撕裂" +drafts: "草稿" +_drafts: + select: "选择草稿" + cannotCreateDraftAnymore: "已超过可创建的草稿数量。" + cannotCreateDraft: "此内容无法创建草稿。" + delete: "删除草稿" + deleteAreYouSure: "要删除草稿吗?" + noDrafts: "没有草稿" + replyTo: "回复给 {user}" + quoteOf: "对 {user} 帖子的引用" + postTo: "向 {channel} 的投稿" + saveToDraft: "保存到草稿" + restoreFromDraft: "从草稿恢复" + restore: "恢复" + listDrafts: "草稿一览" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 8aa5f92871..d0dbecfcba 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -1313,6 +1313,7 @@ availableRoles: "可用角色" acknowledgeNotesAndEnable: "了解注意事項後再開啟。" federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" +draft: "草稿\n" confirmOnReact: "在做出反應前先確認" reactAreYouSure: "用「 {emoji} 」反應嗎?" markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" @@ -1367,6 +1368,9 @@ redisplayAllTips: "重新顯示所有「提示與技巧」" hideAllTips: "隱藏所有「提示與技巧」" defaultImageCompressionLevel: "預設的影像壓縮程度" defaultImageCompressionLevel_description: "低的話可以保留畫質,但是會增加檔案的大小。
高的話可以減少檔案大小,但是會降低畫質。" +_order: + newest: "最新的在前" + oldest: "最舊的在前" _chat: noMessagesYet: "尚無訊息" newMessage: "新訊息" @@ -1993,6 +1997,8 @@ _role: uploadableFileTypes: "可上傳的檔案類型" uploadableFileTypes_caption: "請指定 MIME 類型。可以用換行區隔多個類型,也可以使用星號(*)作為萬用字元進行指定。(例如:image/*)\n" uploadableFileTypes_caption2: "有些檔案可能無法判斷其類型。若要允許這類檔案,請在指定中加入 {x}。" + noteDraftLimit: "伺服器端可建立的貼文草稿數量上限\n" + watermarkAvailable: "浮水印功能是否可用" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -2152,6 +2158,7 @@ _theme: install: "安裝佈景主題" manage: "管理佈景主題" code: "佈景主題代碼" + copyThemeCode: "複製主題代碼" description: "描述" installed: "{name}已安裝" installedThemes: "已經安裝的佈景主題" @@ -2800,6 +2807,7 @@ _fileViewer: url: "URL" uploadedAt: "加入日期" attachedNotes: "含有附件的貼文" + usage: "使用情況" thisPageCanBeSeenFromTheAuthor: "本頁面僅限上傳了這個檔案的使用者可以檢視。" _externalResourceInstaller: title: "從外部網站安裝" @@ -3103,6 +3111,7 @@ _serverSetupWizard: text2: "為了能夠繼續開發,若您願意的話,請考慮進行捐款。\n" text3: "也有提供支援者專屬的特典!\n" _uploader: + editImage: "編輯圖片" compressedToX: "壓縮為 {x}" savedXPercent: "節省了 {x}%" abortConfirm: "有些檔案尚未上傳,您要中止嗎?" @@ -3171,3 +3180,18 @@ _imageEffector: checker: "棋盤格" blockNoise: "阻擋雜訊" tearing: "撕裂" +drafts: "草稿\n" +_drafts: + select: "選擇草槁" + cannotCreateDraftAnymore: "已超出可建立的草稿數量上限。\n" + cannotCreateDraft: "無法以此內容建立草稿。\n" + delete: "刪除草稿" + deleteAreYouSure: "確定要刪除草稿嗎?\n" + noDrafts: "沒有草稿。\n" + replyTo: "回覆給 {user}\n" + quoteOf: "引用自 {user} 的貼文\n" + postTo: "發佈到 {channel}\n" + saveToDraft: "儲存為草稿" + restoreFromDraft: "從草稿復原\n" + restore: "還原" + listDrafts: "草稿清單" diff --git a/package.json b/package.json index 11f236fec0..7bfca56065 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2025.6.3", + "version": "2025.7.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@10.12.1", + "packageManager": "pnpm@10.13.1", "workspaces": [ "packages/frontend-shared", "packages/frontend", @@ -52,29 +52,29 @@ "lodash": "4.17.21" }, "dependencies": { - "cssnano": "7.0.7", - "esbuild": "0.25.5", + "cssnano": "7.1.0", + "esbuild": "0.25.6", "execa": "9.6.0", "fast-glob": "3.3.3", - "glob": "11.0.2", + "glob": "11.0.3", "ignore-walk": "7.0.0", "js-yaml": "4.1.0", - "postcss": "8.5.4", + "postcss": "8.5.6", "tar": "7.4.3", - "terser": "5.42.0", + "terser": "5.43.1", "typescript": "5.8.3" }, "devDependencies": { "@misskey-dev/eslint-plugin": "2.1.0", - "@types/node": "22.15.31", - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", + "@types/node": "22.16.4", + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", "cross-env": "7.0.3", - "cypress": "14.4.1", - "eslint": "9.28.0", - "globals": "16.2.0", + "cypress": "14.5.2", + "eslint": "9.31.0", + "globals": "16.3.0", "ncp": "2.0.0", - "pnpm": "10.12.1", + "pnpm": "10.13.1", "start-server-and-test": "2.0.12" }, "optionalDependencies": { @@ -83,6 +83,9 @@ "pnpm": { "overrides": { "@aiscript-dev/aiscript-languageserver": "-" + }, + "patchedDependencies": { + "typeorm": "patches/typeorm.patch" } } } diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index d15a703ba2..ba7c705def 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -25,6 +25,7 @@ export default [ }, }, rules: { + '@typescript-eslint/no-unused-vars': 'off', 'import/order': ['warn', { groups: [ 'builtin', diff --git a/packages/backend/migration/1736686850345-createNoteDraft.js b/packages/backend/migration/1736686850345-createNoteDraft.js new file mode 100644 index 0000000000..3b525a7339 --- /dev/null +++ b/packages/backend/migration/1736686850345-createNoteDraft.js @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateNoteDraft1736686850345 { + name = 'CreateNoteDraft1736686850345' + + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE "note_draft" ( + "id" varchar NOT NULL, + "replyId" varchar NULL, + "renoteId" varchar NULL, + "text" text NULL, + "cw" varchar(512) NULL, + "userId" varchar NOT NULL, + "localOnly" boolean DEFAULT false, + "reactionAcceptance" varchar(64) NULL, + "visibility" varchar NOT NULL, + "fileIds" varchar[] DEFAULT '{}', + "visibleUserIds" varchar[] DEFAULT '{}', + "hashtag" varchar(128) NULL, + "channelId" varchar NULL, + "hasPoll" boolean DEFAULT false, + "pollChoices" varchar(256)[] DEFAULT '{}', + "pollMultiple" boolean NULL, + "pollExpiresAt" TIMESTAMP WITH TIME ZONE NULL, + "pollExpiredAfter" bigint NULL, + PRIMARY KEY ("id") + )`); + + await queryRunner.query(` + CREATE INDEX "IDX_NOTE_DRAFT_REPLY_ID" ON "note_draft" ("replyId") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_NOTE_DRAFT_RENOTE_ID" ON "note_draft" ("renoteId") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_NOTE_DRAFT_USER_ID" ON "note_draft" ("userId") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_NOTE_DRAFT_FILE_IDS" ON "note_draft" USING GIN ("fileIds") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_NOTE_DRAFT_VISIBLE_USER_IDS" ON "note_draft" USING GIN ("visibleUserIds") + `); + + await queryRunner.query(` + CREATE INDEX "IDX_NOTE_DRAFT_CHANNEL_ID" ON "note_draft" ("channelId") + `); + + await queryRunner.query(` + ALTER TABLE "note_draft" + ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "note_draft" + ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "note_draft" + ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "note_draft" + ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE + `); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`); + await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_CHANNEL_ID"`); + await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_VISIBLE_USER_IDS"`); + await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_FILE_IDS"`); + await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_USER_ID"`); + await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_RENOTE_ID"`); + await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_REPLY_ID"`); + await queryRunner.query(`DROP TABLE "note_draft"`); + } +} diff --git a/packages/backend/migration/1750729939704-FixAvatarUrl.js b/packages/backend/migration/1750729939704-FixAvatarUrl.js new file mode 100644 index 0000000000..81a598136b --- /dev/null +++ b/packages/backend/migration/1750729939704-FixAvatarUrl.js @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +export class FixAvatarUrl1750729939704 { + name = 'FixAvatarUrl1750729939704' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "avatarUrl" TYPE character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "avatarUrl" TYPE character varying(512)`); + } +} diff --git a/packages/backend/migration/1752502434151-no-action-on-draft-relation.js b/packages/backend/migration/1752502434151-no-action-on-draft-relation.js new file mode 100644 index 0000000000..e3c63b79c7 --- /dev/null +++ b/packages/backend/migration/1752502434151-no-action-on-draft-relation.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoActionOnDraftRelation1752502434151 { + name = 'NoActionOnDraftRelation1752502434151' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_e4983f28b4b18b03491536052f5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_e4983f28b4b18b03491536052f5"`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/migration/1752509043847-migration-cleanup.js b/packages/backend/migration/1752509043847-migration-cleanup.js new file mode 100644 index 0000000000..450e22af0c --- /dev/null +++ b/packages/backend/migration/1752509043847-migration-cleanup.js @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MigrationCleanup1752509043847 { + name = 'MigrationCleanup1752509043847' + + async up(queryRunner) { + // 1745378064470-composite-note-index.js created a index ON "note" ("userId", "id" DESC) as IDX_724b311e6f883751f261ebe378 but should be named IDX_a6f649630f55af3888e5a42919 + await queryRunner.query(`ALTER INDEX "IDX_724b311e6f883751f261ebe378" RENAME TO "IDX_a6f649630f55af3888e5a42919"`); + + // 1713656541000-abuse-report-notification.js generated system_webhook with hand-written SQL with CURRENT_TIMESTAMP as the default value, but its representation in TypeORM is `now()` + // see https://github.com/typeorm/typeorm/blob/f351757a15b9d2bd9d4222c69dcfd2316f46b5d1/src/driver/postgres/PostgresDriver.ts#L1575 + await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT now()`); + + // 1702718871541-ffVisibility.js defined a enum type "user_profile_followersVisibility_enum" but it should be "user_profile_followersvisibility_enum" (lowercase 'v') in typeorm + await queryRunner.query(`ALTER TYPE "public"."user_profile_followersVisibility_enum" RENAME TO "user_profile_followersvisibility_enum"`); + + // 1713656541000-abuse-report-notification.js generated abuse_report_notification_recipient with hand-written SQL with CURRENT_TIMESTAMP as the default value, but its representation in TypeORM is `now()` + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT now()`); + + // 1690796169261-play-visibility.js added visibility column to flash table but it forgot to set NOT NULL constraint + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" SET NOT NULL`); + + // 1736686850345-createNoteDraft.js created note_draft with hand-written SQL but several types and comments are not correctly defined + await queryRunner.query(`CREATE TYPE "public"."note_draft_visibility_enum" AS ENUM('public', 'home', 'followers', 'specified')`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "id" SET DATA TYPE character varying(32)`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "replyId" SET DATA TYPE character varying(32)`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "renoteId" SET DATA TYPE character varying(32)`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "userId" SET DATA TYPE character varying(32)`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "channelId" SET DATA TYPE character varying(32)`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET DATA TYPE character varying(32) array`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET DATA TYPE character varying(32) array`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibility" SET DATA TYPE "public"."note_draft_visibility_enum" USING visibility::note_draft_visibility_enum`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."replyId" IS 'The ID of reply target.'`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."renoteId" IS 'The ID of renote target.'`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."userId" IS 'The ID of author.'`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."channelId" IS 'The ID of source channel.'`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "hasPoll" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollChoices" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollMultiple" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "localOnly" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET NOT NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "localOnly" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollMultiple" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollChoices" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "hasPoll" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" DROP NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."channelId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."userId" IS 'The ID of author.'`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."renoteId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note_draft"."replyId" IS NULL`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibility" SET DATA TYPE varchar`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET DATA TYPE varchar[]`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET DATA TYPE varchar[]`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "channelId" SET DATA TYPE varchar`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "userId" SET DATA TYPE varchar`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "renoteId" SET DATA TYPE varchar`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "replyId" SET DATA TYPE varchar`); + await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "id" SET DATA TYPE varchar`); + await queryRunner.query(`DROP TYPE "public"."note_draft_visibility_enum"`); + + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`); + + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`); + + await queryRunner.query(`ALTER TYPE "public"."user_profile_followersvisibility_enum" RENAME TO "user_profile_followersVisibility_enum"`); + + await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`); + + await queryRunner.query(`ALTER INDEX "IDX_a6f649630f55af3888e5a42919" RENAME TO "IDX_724b311e6f883751f261ebe378"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 2173ce71a5..1a25f63b83 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": "^20.10.0 || ^22.0.0" + "node": "^20.18.1 || ^22.0.0" }, "scripts": { "start": "node ./built/boot/entry.js", @@ -33,6 +33,7 @@ "test:fed": "pnpm jest:fed", "test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", + "check-migrations": "node scripts/check_migrations_clean.js", "generate-api-json": "node ./scripts/generate_api_json.js" }, "optionalDependencies": { diff --git a/packages/backend/scripts/check_migrations_clean.js b/packages/backend/scripts/check_migrations_clean.js new file mode 100644 index 0000000000..ce67b1cd81 --- /dev/null +++ b/packages/backend/scripts/check_migrations_clean.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// This script checks if the database migrations has been generated correctly. + +import dataSource from '../ormconfig.js'; + +await dataSource.initialize(); + +const sqlInMemory = await dataSource.driver.createSchemaBuilder().log(); + +if (sqlInMemory.upQueries.length > 0 || sqlInMemory.downQueries.length > 0) { + console.error('There are several pending migrations. Please make sure you have generated the migrations correctly, or configured entities class correctly.'); + for (const query of sqlInMemory.upQueries) { + console.error(`- ${query.query}`); + } + for (const query of sqlInMemory.downQueries) { + console.error(`- ${query.query}`); + } + process.exit(1); +} else { + console.log('All migrations are clean.'); + process.exit(0); +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index d8617e343c..0c0c5d3a39 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -44,6 +44,7 @@ import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; +import { NoteDraftService } from './NoteDraftService.js'; import { NotificationService } from './NotificationService.js'; import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; @@ -118,6 +119,7 @@ import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService. import { NoteEntityService } from './entities/NoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; +import { NoteDraftEntityService } from './entities/NoteDraftEntityService.js'; import { NotificationEntityService } from './entities/NotificationEntityService.js'; import { PageEntityService } from './entities/PageEntityService.js'; import { PageLikeEntityService } from './entities/PageLikeEntityService.js'; @@ -185,6 +187,7 @@ 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 $NoteDraftService: Provider = { provide: 'NoteDraftService', useExisting: NoteDraftService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService }; @@ -266,6 +269,7 @@ const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityServi const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; +const $NoteDraftEntityService: Provider = { provide: 'NoteDraftEntityService', useExisting: NoteDraftEntityService }; const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService }; const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting: PageEntityService }; const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService }; @@ -335,6 +339,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, + NoteDraftService, NotificationService, PollService, SystemAccountService, @@ -416,6 +421,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, + NoteDraftEntityService, NotificationEntityService, PageEntityService, PageLikeEntityService, @@ -481,6 +487,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, + $NoteDraftService, $NotificationService, $PollService, $SystemAccountService, @@ -562,6 +569,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, + $NoteDraftEntityService, $NotificationEntityService, $PageEntityService, $PageLikeEntityService, @@ -628,6 +636,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, + NoteDraftService, NotificationService, PollService, SystemAccountService, @@ -708,6 +717,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, + NoteDraftEntityService, NotificationEntityService, PageEntityService, PageLikeEntityService, @@ -773,6 +783,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, + $NoteDraftService, $NotificationService, $PollService, $SystemAccountService, @@ -852,6 +863,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, + $NoteDraftEntityService, $NotificationEntityService, $PageEntityService, $PageLikeEntityService, diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 45d7ea11e4..c7be0f7843 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -145,7 +145,10 @@ export class EmailService { try { // TODO: htmlサニタイズ const info = await transporter.sendMail({ - from: this.meta.email!, + from: this.meta.name ? { + name: this.meta.name, + address: this.meta.email!, + } : this.meta.email!, to: to, subject: subject, text: text, diff --git a/packages/backend/src/core/FlashService.ts b/packages/backend/src/core/FlashService.ts index 2a98225382..8caffe9e45 100644 --- a/packages/backend/src/core/FlashService.ts +++ b/packages/backend/src/core/FlashService.ts @@ -4,8 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import { type FlashsRepository } from '@/models/_.js'; +import { type FlashLikesRepository, MiUser, type FlashsRepository } from '@/models/_.js'; +import { QueryService } from '@/core/QueryService.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; /** * MisskeyPlay関係のService @@ -15,6 +18,11 @@ export class FlashService { constructor( @Inject(DI.flashsRepository) private flashRepository: FlashsRepository, + + @Inject(DI.flashLikesRepository) + private flashLikesRepository: FlashLikesRepository, + + private queryService: QueryService, ) { } @@ -37,4 +45,43 @@ export class FlashService { return await builder.getMany(); } + + public async myLikes(meId: MiUser['id'], opts: { sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number, limit?: number, search?: string | null }) { + const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), opts.sinceId, opts.untilId, opts.sinceDate, opts.untilDate) + .andWhere('like.userId = :meId', { meId }) + .leftJoinAndSelect('like.flash', 'flash'); + + if (opts.search != null) { + for (const word of opts.search.trim().split(' ')) { + query.andWhere(new Brackets(qb => { + qb.orWhere('flash.title ILIKE :search', { search: `%${sqlLikeEscape(word)}%` }); + qb.orWhere('flash.summary ILIKE :search', { search: `%${sqlLikeEscape(word)}%` }); + })); + } + } + + const likes = await query + .limit(opts.limit) + .getMany(); + + return likes; + } + + public async search(searchQuery: string, opts: { sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number, limit?: number }) { + const query = this.queryService.makePaginationQuery(this.flashRepository.createQueryBuilder('flash'), opts.sinceId, opts.untilId, opts.sinceDate, opts.untilDate) + .andWhere('flash.visibility = \'public\''); + + for (const word of searchQuery.trim().split(' ')) { + query.andWhere(new Brackets(qb => { + qb.orWhere('flash.title ILIKE :search', { search: `%${sqlLikeEscape(word)}%` }); + qb.orWhere('flash.summary ILIKE :search', { search: `%${sqlLikeEscape(word)}%` }); + })); + } + + const result = await query + .limit(opts.limit) + .getMany(); + + return result; + } } diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts new file mode 100644 index 0000000000..c43be96efa --- /dev/null +++ b/packages/backend/src/core/NoteDraftService.ts @@ -0,0 +1,314 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type { noteVisibilities, noteReactionAcceptances } from '@/types.js'; +import { DI } from '@/di-symbols.js'; +import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; +import { IPoll } from '@/models/Poll.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { isRenote, isQuote } from '@/misc/is-renote.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; + +export type NoteDraftOptions = { + replyId?: MiNote['id'] | null; + renoteId?: MiNote['id'] | null; + text?: string | null; + cw?: string | null; + localOnly?: boolean | null; + reactionAcceptance?: typeof noteReactionAcceptances[number]; + visibility?: typeof noteVisibilities[number]; + fileIds?: MiDriveFile['id'][]; + visibleUserIds?: MiUser['id'][]; + hashtag?: string; + channelId?: MiChannel['id'] | null; + poll?: (IPoll & { expiredAfter?: number | null }) | null; +}; + +@Injectable() +export class NoteDraftService { + constructor( + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.noteDraftsRepository) + private noteDraftsRepository: NoteDraftsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private roleService: RoleService, + private idService: IdService, + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public async get(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise { + const draft = await this.noteDraftsRepository.findOneBy({ + id: draftId, + userId: me.id, + }); + + return draft; + } + + @bindThis + public async create(me: MiLocalUser, data: NoteDraftOptions): Promise { + //#region check draft limit + + const currentCount = await this.noteDraftsRepository.countBy({ + userId: me.id, + }); + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) { + throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts'); + } + //#endregion + + if (data.poll) { + if (typeof data.poll.expiresAt === 'number') { + if (data.poll.expiresAt < Date.now()) { + throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); + } + } else if (typeof data.poll.expiredAfter === 'number') { + data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + } + } + + const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data); + + appliedDraft.id = this.idService.gen(); + appliedDraft.userId = me.id; + const draft = this.noteDraftsRepository.save(appliedDraft); + + return draft; + } + + @bindThis + public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise { + const draft = await this.noteDraftsRepository.findOneBy({ + id: draftId, + userId: me.id, + }); + + if (draft == null) { + throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); + } + + if (data.poll) { + if (typeof data.poll.expiresAt === 'number') { + if (data.poll.expiresAt < Date.now()) { + throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); + } + } else if (typeof data.poll.expiredAfter === 'number') { + data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + } + } + + const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data); + + return await this.noteDraftsRepository.save(appliedDraft); + } + + @bindThis + public async delete(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise { + const draft = await this.noteDraftsRepository.findOneBy({ + id: draftId, + userId: me.id, + }); + + if (draft == null) { + throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); + } + + await this.noteDraftsRepository.delete(draft.id); + } + + @bindThis + public async getDraft(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise { + const draft = await this.noteDraftsRepository.findOneBy({ + id: draftId, + userId: me.id, + }); + + if (draft == null) { + throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); + } + + return draft; + } + + // 関連エンティティを取得し紐づける部分を共通化する + @bindThis + public async checkAndSetDraftNoteOptions( + me: MiLocalUser, + draft: MiNoteDraft, + data: NoteDraftOptions, + ): Promise { + data.visibility ??= 'public'; + data.localOnly ??= false; + if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; + if (data.channelId != null) { + data.visibility = 'public'; + data.visibleUserIds = []; + data.localOnly = true; + } + + let appliedDraft = draft; + + //#region visibleUsers + let visibleUsers: MiUser[] = []; + if (data.visibleUserIds != null) { + visibleUsers = await this.usersRepository.findBy({ + id: In(data.visibleUserIds), + }); + } + //#endregion + + //#region files + let files: MiDriveFile[] = []; + const fileIds = data.fileIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds: fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new IdentifiableError('b6992544-63e7-67f0-fa7f-32444b1b5306', 'No such drive file'); + } + } + //#endregion + + //#region renote + let renote: MiNote | null = null; + if (data.renoteId != null) { + renote = await this.notesRepository.findOneBy({ id: data.renoteId }); + + if (renote == null) { + throw new IdentifiableError('64929870-2540-4d11-af41-3b484d78c956', 'No such renote'); + } else if (isRenote(renote) && !isQuote(renote)) { + throw new IdentifiableError('76cc5583-5a14-4ad3-8717-0298507e32db', 'Cannot renote'); + } + + // Check blocking + if (renote.userId !== me.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user'); + } + } + + if (renote.visibility === 'followers' && renote.userId !== me.id) { + // 他人のfollowers noteはreject + throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility'); + } else if (renote.visibility === 'specified') { + // specified / direct noteはreject + throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility'); + } + + if (renote.channelId && renote.channelId !== data.channelId) { + // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック + // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する + const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); + if (renoteChannel == null) { + // リノートしたいノートが書き込まれているチャンネルがない + throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel'); + } else if (!renoteChannel.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new IdentifiableError('ed1952ac-2d26-4957-8b30-2deda76bedf7', 'Cannot Renote to External'); + } + } + } + //#endregion + + //#region reply + let reply: MiNote | null = null; + if (data.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: data.replyId }); + + if (reply == null) { + throw new IdentifiableError('c4721841-22fc-4bb7-ad3d-897ef1d375b5', 'No such reply'); + } else if (isRenote(reply) && !isQuote(reply)) { + throw new IdentifiableError('e6c10b57-2c09-4da3-bd4d-eda05d51d140', 'Cannot reply To Pure Renote'); + } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { + throw new IdentifiableError('593c323c-6b6a-4501-a25c-2f36bd2a93d6', 'Cannot reply To Invisible Note'); + } else if (reply.visibility === 'specified' && data.visibility !== 'specified') { + throw new IdentifiableError('215dbc76-336c-4d2a-9605-95766ba7dab0', 'Cannot reply To Specified Note With Extended Visibility'); + } + + // Check blocking + if (reply.userId !== me.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user'); + } + } + } + //#endregion + + //#region channel + let channel: MiChannel | null = null; + if (data.channelId != null) { + channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false }); + + if (channel == null) { + throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel'); + } + } + //#endregion + + appliedDraft = { + ...appliedDraft, + visibility: data.visibility, + cw: data.cw ?? null, + fileIds: fileIds ?? [], + replyId: data.replyId ?? null, + renoteId: data.renoteId ?? null, + channelId: data.channelId ?? null, + text: data.text ?? null, + hashtag: data.hashtag ?? null, + hasPoll: data.poll != null, + pollChoices: data.poll ? data.poll.choices : [], + pollMultiple: data.poll ? data.poll.multiple : false, + pollExpiresAt: data.poll ? data.poll.expiresAt : null, + pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null, + visibleUserIds: data.visibleUserIds ?? [], + localOnly: data.localOnly, + reactionAcceptance: data.reactionAcceptance, + } satisfies MiNoteDraft; + + return appliedDraft; + } +} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 76dafeb255..cddfc0094e 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -66,6 +66,8 @@ export type RolePolicies = { canImportUserLists: boolean; chatAvailability: 'available' | 'readonly' | 'unavailable'; uploadableFileTypes: string[]; + noteDraftLimit: number; + watermarkAvailable: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -109,6 +111,8 @@ export const DEFAULT_POLICIES: RolePolicies = { 'video/*', 'audio/*', ], + noteDraftLimit: 10, + watermarkAvailable: true, }; @Injectable() @@ -430,6 +434,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } return [...set]; }), + noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)), + watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 5462cb0b13..a85da62b86 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -93,6 +93,11 @@ export class SignupService { if (isPreserved) { throw new Error('USED_USERNAME'); } + + const hasProhibitedWords = this.utilityService.isKeyWordIncluded(username.toLowerCase(), this.meta.prohibitedWordsForNameOfUser); + if (hasProhibitedWords) { + throw new Error('USED_USERNAME'); + } } const keyPair = await new Promise((res, rej) => diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts new file mode 100644 index 0000000000..3ef8cdaa12 --- /dev/null +++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { EntityNotFoundError } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { MiUser, MiNote, MiNoteDraft } from '@/models/_.js'; +import type { NoteDraftsRepository, ChannelsRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { DebounceLoader } from '@/misc/loader.js'; +import { IdService } from '@/core/IdService.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { UserEntityService } from './UserEntityService.js'; +import type { DriveFileEntityService } from './DriveFileEntityService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; + +@Injectable() +export class NoteDraftEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private driveFileEntityService: DriveFileEntityService; + private idService: IdService; + private noteEntityService: NoteEntityService; + private noteDraftLoader = new DebounceLoader(this.findNoteDraftOrFail); + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.noteDraftsRepository) + private noteDraftsRepository: NoteDraftsRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); + this.idService = this.moduleRef.get('IdService'); + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + } + + @bindThis + public async packAttachedFiles(fileIds: MiNote['fileIds'], packedFiles: Map | null>): Promise[]> { + const missingIds = []; + for (const id of fileIds) { + if (!packedFiles.has(id)) missingIds.push(id); + } + if (missingIds.length) { + const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); + for (const [k, v] of additionalMap) { + packedFiles.set(k, v); + } + } + return fileIds.map(id => packedFiles.get(id)).filter(x => x != null); + } + + @bindThis + public async pack( + src: MiNoteDraft['id'] | MiNoteDraft, + me?: { id: MiUser['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + withReactionAndUserPairCache?: boolean; + _hint_?: { + packedFiles: Map | null>; + packedUsers: Map> + }; + }, + ): Promise> { + const opts = Object.assign({ + detail: true, + }, options); + + const noteDraft = typeof src === 'object' ? src : await this.noteDraftLoader.load(src); + + const text = noteDraft.text; + + const channel = noteDraft.channelId + ? noteDraft.channel + ? noteDraft.channel + : await this.channelsRepository.findOneBy({ id: noteDraft.channelId }) + : null; + + const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; + + async function nullIfEntityNotFound(promise: Promise): Promise { + try { + return await promise; + } catch (err) { + if (err instanceof EntityNotFoundError) { + return null; + } + throw err; + } + } + + const packed: Packed<'NoteDraft'> = await awaitAll({ + id: noteDraft.id, + createdAt: this.idService.parse(noteDraft.id).date.toISOString(), + userId: noteDraft.userId, + user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me), + text: text, + cw: noteDraft.cw, + visibility: noteDraft.visibility, + localOnly: noteDraft.localOnly, + reactionAcceptance: noteDraft.reactionAcceptance, + visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined, + hashtag: noteDraft.hashtag ?? undefined, + fileIds: noteDraft.fileIds, + files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds), + replyId: noteDraft.replyId, + renoteId: noteDraft.renoteId, + channelId: noteDraft.channelId ?? undefined, + channel: channel ? { + id: channel.id, + name: channel.name, + color: channel.color, + isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, + userId: channel.userId, + } : undefined, + + ...(opts.detail ? { + reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, { + detail: false, + skipHide: opts.skipHide, + })) : undefined, + + renote: noteDraft.renoteId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.renoteId, me, { + detail: true, + skipHide: opts.skipHide, + })) : undefined, + + poll: noteDraft.hasPoll ? { + choices: noteDraft.pollChoices, + multiple: noteDraft.pollMultiple, + expiresAt: noteDraft.pollExpiresAt?.toISOString(), + expiredAfter: noteDraft.pollExpiredAfter, + } : undefined, + } : {} ), + }); + + return packed; + } + + @bindThis + public async packMany( + noteDrafts: MiNoteDraft[], + me?: { id: MiUser['id'] } | null | undefined, + options?: { + detail?: boolean; + }, + ) { + if (noteDrafts.length === 0) return []; + + // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく + const fileIds = noteDrafts.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null); + const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); + const users = [ + ...noteDrafts.map(({ user, userId }) => user ?? userId), + ]; + const packedUsers = await this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + + return await Promise.all(noteDrafts.map(n => this.pack(n, me, { + ...options, + _hint_: { + packedFiles, + packedUsers, + }, + }))); + } + + @bindThis + private findNoteDraftOrFail(id: string): Promise { + return this.noteDraftsRepository.findOneOrFail({ + where: { id }, + relations: ['user'], + }); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 77d2838e09..c915133453 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -89,5 +89,6 @@ export const DI = { chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), + noteDraftsRepository: Symbol('noteDraftsRepository'), //#endregion }; diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 5e5d7041b9..ed47edff9b 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -72,6 +72,7 @@ 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'; import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js'; +import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -89,6 +90,7 @@ export const refs = { Announcement: packedAnnouncementSchema, App: packedAppSchema, Note: packedNoteSchema, + NoteDraft: packedNoteDraftSchema, NoteReaction: packedNoteReactionSchema, NoteFavorite: packedNoteFavoriteSchema, Notification: packedNotificationSchema, diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts index fbff880afc..17ec6abed5 100644 --- a/packages/backend/src/models/AbuseReportNotificationRecipient.ts +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -22,7 +22,7 @@ export class MiAbuseReportNotificationRecipient { /** * 有効かどうか. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_isActive') @Column('boolean', { default: true, }) @@ -47,7 +47,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知方法. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_method') @Column('varchar', { length: 64, }) @@ -56,10 +56,11 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザID. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_userId') @Column({ ...id(), nullable: true, + default: null, }) public userId: MiUser['id'] | null; @@ -75,17 +76,20 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザプロフィール. */ - @ManyToOne(type => MiUserProfile, {}) + @ManyToOne(type => MiUserProfile, { + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) public userProfile: MiUserProfile | null; /** * 通知先のシステムWebhookId. */ - @Index() + @Index('IDX_abuse_report_notification_recipient_systemWebhookId') @Column({ ...id(), nullable: true, + default: null, }) public systemWebhookId: string | null; @@ -95,6 +99,6 @@ export class MiAbuseReportNotificationRecipient { @ManyToOne(type => MiSystemWebhook, { onDelete: 'CASCADE', }) - @JoinColumn() + @JoinColumn({ name: 'systemWebhookId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId' }) public systemWebhook: MiSystemWebhook | null; } diff --git a/packages/backend/src/models/Emoji.ts b/packages/backend/src/models/Emoji.ts index d62b6e9f6f..8dff8fd153 100644 --- a/packages/backend/src/models/Emoji.ts +++ b/packages/backend/src/models/Emoji.ts @@ -8,6 +8,7 @@ import { id } from './util/id.js'; @Entity('emoji') @Index(['name', 'host'], { unique: true }) +@Index('IDX_EMOJI_ROLE_IDS', { synchronize: false }) // GIN for roleIdsThatCanBeUsedThisEmojiAsReaction in production export class MiEmoji { @PrimaryColumn(id()) public id: string; @@ -32,6 +33,7 @@ export class MiEmoji { @Column('varchar', { length: 128, nullable: true, }) + @Index('IDX_EMOJI_CATEGORY') public category: string | null; @Column('varchar', { diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 3ee6190d45..85c10ab666 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -59,7 +59,7 @@ export class MiMeta { public maintainerEmail: string | null; @Column('boolean', { - default: false, + default: true, }) public disableRegistration: boolean; @@ -570,7 +570,7 @@ export class MiMeta { public bannedEmailDomains: string[]; @Column('varchar', { - length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', + length: 1024, array: true, default: ['admin', 'administrator', 'root', 'system', 'maintainer', 'host', 'mod', 'moderator', 'owner', 'superuser', 'staff', 'auth', 'i', 'me', 'everyone', 'all', 'mention', 'mentions', 'example', 'user', 'users', 'account', 'accounts', 'official', 'help', 'helps', 'support', 'supports', 'info', 'information', 'informations', 'announce', 'announces', 'announcement', 'announcements', 'notice', 'notification', 'notifications', 'dev', 'developer', 'developers', 'tech', 'misskey'], }) public preservedUsernames: string[]; @@ -635,7 +635,7 @@ export class MiMeta { public urlPreviewMaximumContentLength: number; @Column('boolean', { - default: true, + default: false, }) public urlPreviewRequireContentLength: boolean; @@ -648,6 +648,7 @@ export class MiMeta { @Column('varchar', { length: 1024, nullable: true, + default: null, }) public urlPreviewUserAgent: string | null; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 3dcbdb735b..9822ec94e4 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -4,7 +4,7 @@ */ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; -import { noteVisibilities } from '@/types.js'; +import { noteVisibilities, noteReactionAcceptances } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiChannel } from './Channel.js'; @@ -20,7 +20,8 @@ import type { MiDriveFile } from './DriveFile.js'; // You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail // because it will always run CREATE INDEX in transaction based on decorators. // Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production, -@Index(['userId', 'id']) + +@Index(['userId', 'id']) // Note: this index is ("userId", "id" DESC) in production, but not in test. @Entity('note') export class MiNote { @PrimaryColumn(id()) @@ -96,7 +97,7 @@ export class MiNote { @Column('varchar', { length: 64, nullable: true, }) - public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote' | null; + public reactionAcceptance: typeof noteReactionAcceptances[number]; @Column('smallint', { default: 0, diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts new file mode 100644 index 0000000000..6483748bc2 --- /dev/null +++ b/packages/backend/src/models/NoteDraft.ts @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { noteVisibilities, noteReactionAcceptances } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChannel } from './Channel.js'; +import { MiNote } from './Note.js'; +import type { MiDriveFile } from './DriveFile.js'; + +@Entity('note_draft') +@Index('IDX_NOTE_DRAFT_FILE_IDS', { synchronize: false }) // GIN for fileIds in production +@Index('IDX_NOTE_DRAFT_VISIBLE_USER_IDS', { synchronize: false }) // GIN for visibleUserIds in production +export class MiNoteDraft { + @PrimaryColumn(id()) + public id: string; + + @Index('IDX_NOTE_DRAFT_REPLY_ID') + @Column({ + ...id(), + nullable: true, + comment: 'The ID of reply target.', + }) + public replyId: MiNote['id'] | null; + + // There is a possibility that replyId is not null but reply is null when the reply note is deleted. + @ManyToOne(type => MiNote, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public reply: MiNote | null; + + @Index('IDX_NOTE_DRAFT_RENOTE_ID') + @Column({ + ...id(), + nullable: true, + comment: 'The ID of renote target.', + }) + public renoteId: MiNote['id'] | null; + + // There is a possibility that renoteId is not null but renote is null when the renote note is deleted. + @ManyToOne(type => MiNote, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public renote: MiNote | null; + + // TODO: varcharにしたい(Note.tsと同じ) + @Column('text', { + nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public cw: string | null; + + @Index('IDX_NOTE_DRAFT_USER_ID') + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('varchar', { + length: 64, nullable: true, + }) + public reactionAcceptance: typeof noteReactionAcceptances[number]; + + /** + * public ... 公開 + * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column('enum', { enum: noteVisibilities }) + public visibility: typeof noteVisibilities[number]; + + @Index('IDX_NOTE_DRAFT_FILE_IDS', { synchronize: false }) + @Column({ + ...id(), + array: true, default: '{}', + }) + public fileIds: MiDriveFile['id'][]; + + @Index('IDX_NOTE_DRAFT_VISIBLE_USER_IDS', { synchronize: false }) + @Column({ + ...id(), + array: true, default: '{}', + }) + public visibleUserIds: MiUser['id'][]; + + @Column('varchar', { + length: 128, nullable: true, + }) + public hashtag: string | null; + + @Index('IDX_NOTE_DRAFT_CHANNEL_ID') + @Column({ + ...id(), + nullable: true, + comment: 'The ID of source channel.', + }) + public channelId: MiChannel['id'] | null; + + // There is a possibility that channelId is not null but channel is null when the channel is deleted. + // (deleting channel is not implemented so it's not happening now but may happen in the future) + @ManyToOne(type => MiChannel, { + createForeignKeyConstraints: false, + }) + @JoinColumn() + public channel: MiChannel | null; + + // 以下、Pollについて追加 + + @Column('boolean', { + default: false, + }) + public hasPoll: boolean; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public pollChoices: string[]; + + @Column('boolean') + public pollMultiple: boolean; + + @Column('timestamp with time zone', { + nullable: true, + }) + public pollExpiresAt: Date | null; + + @Column('bigint', { + nullable: true, + }) + public pollExpiredAfter: number | null; + + // ここまで追加 + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index b7142d91bf..146dbbc3b8 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -42,6 +42,7 @@ import { MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, + MiNoteDraft, MiPage, MiPageLike, MiPasswordResetRequest, @@ -140,6 +141,12 @@ const $noteReactionsRepository: Provider = { inject: [DI.db], }; +const $noteDraftsRepository: Provider = { + provide: DI.noteDraftsRepository, + useFactory: (db: DataSource) => db.getRepository(MiNoteDraft).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -542,6 +549,7 @@ const $reversiGamesRepository: Provider = { $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, + $noteDraftsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -618,6 +626,7 @@ const $reversiGamesRepository: Provider = { $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, + $noteDraftsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index baf4eefdf1..a6e9edcf5f 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -120,7 +120,7 @@ export class MiUser { // avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること @Column('varchar', { - length: 512, nullable: true, + length: 1024, nullable: true, }) public avatarUrl: string | null; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index c4c1fa5ec9..501b539210 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -29,7 +29,7 @@ export class MiUserProfile { }) public location: string | null; - @Index() + // Note: There's index named IDX_de22cd2b445eee31ae51cdbe99 for SUBSTR("birthday", 6, 5) @Column('char', { length: 10, nullable: true, comment: 'The birthday (YYYY-MM-DD) of the User.', diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index e1ea2a2604..84b5cbed0a 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -55,6 +55,7 @@ import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiNote } from '@/models/Note.js'; +import { MiNoteDraft } from '@/models/NoteDraft.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -188,6 +189,7 @@ export { MiMuting, MiRenoteMuting, MiNote, + MiNoteDraft, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -266,6 +268,7 @@ export type ModerationLogsRepository = Repository & MiRepositor export type MutingsRepository = Repository & MiRepository; export type RenoteMutingsRepository = Repository & MiRepository; export type NotesRepository = Repository & MiRepository; +export type NoteDraftsRepository = Repository & MiRepository; export type NoteFavoritesRepository = Repository & MiRepository; export type NoteReactionsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts index 42b2172409..d50200e6e9 100644 --- a/packages/backend/src/models/json-schema/flash.ts +++ b/packages/backend/src/models/json-schema/flash.ts @@ -51,7 +51,7 @@ export const packedFlashSchema = { }, likedCount: { type: 'number', - optional: false, nullable: true, + optional: false, nullable: false, }, isLiked: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts new file mode 100644 index 0000000000..504b263a6d --- /dev/null +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedNoteDraftSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + cw: { + type: 'string', + optional: true, nullable: true, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + replyId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + renoteId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + reply: { + type: 'object', + optional: true, nullable: true, + ref: 'Note', + description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.', + }, + renote: { + type: 'object', + optional: true, nullable: true, + ref: 'Note', + description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.', + }, + visibility: { + type: 'string', + optional: false, nullable: false, + enum: ['public', 'home', 'followers', 'specified'], + }, + visibleUserIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + fileIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + files: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, + }, + hashtag: { + type: 'string', + optional: true, nullable: false, + }, + poll: { + type: 'object', + optional: true, nullable: true, + properties: { + expiresAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, + expiredAfter: { + type: 'number', + optional: true, nullable: true, + }, + multiple: { + type: 'boolean', + optional: false, nullable: false, + }, + choices: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + channelId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + example: 'xxxxxxxxxx', + }, + channel: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + color: { + type: 'string', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + allowRenoteToExternal: { + type: 'boolean', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + localOnly: { + type: 'boolean', + optional: true, nullable: false, + }, + reactionAcceptance: { + type: 'string', + optional: false, nullable: true, + enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null], + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 8bd01c92a3..c9cdbd5d89 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -309,6 +309,14 @@ export const packedRolePoliciesSchema = { optional: false, nullable: false, enum: ['available', 'readonly', 'unavailable'], }, + noteDraftLimit: { + type: 'integer', + optional: false, nullable: false, + }, + watermarkAvailable: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index b06895fcc9..f6cbbbe64c 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -45,6 +45,7 @@ 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 { MiNoteDraft } from '@/models/NoteDraft.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -210,6 +211,7 @@ export const entities = [ MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, + MiNoteDraft, MiPage, MiPageLike, MiGalleryPost, diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index 728fc9e72b..782b74f0cd 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -34,6 +34,11 @@ export class CleanRemoteFilesProcessorService { let deletedCount = 0; let cursor: MiDriveFile['id'] | null = null; + const total = await this.driveFilesRepository.countBy({ + userHost: Not(IsNull()), + isLink: false, + }); + while (true) { const files = await this.driveFilesRepository.find({ where: { @@ -58,12 +63,7 @@ export class CleanRemoteFilesProcessorService { deletedCount += 8; - const total = await this.driveFilesRepository.countBy({ - userHost: Not(IsNull()), - isLink: false, - }); - - job.updateProgress(100 / total * deletedCount); + job.updateProgress(deletedCount * total / 100); } this.logger.succ('All cached remote files has been deleted.'); diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index 291fa4a6d8..5a8a8ca940 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -43,6 +43,10 @@ export class DeleteDriveFilesProcessorService { let deletedCount = 0; let cursor: MiDriveFile['id'] | null = null; + const total = await this.driveFilesRepository.countBy({ + userId: user.id, + }); + while (true) { const files = await this.driveFilesRepository.find({ where: { @@ -67,11 +71,7 @@ export class DeleteDriveFilesProcessorService { deletedCount++; } - const total = await this.driveFilesRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(deletedCount / total); + job.updateProgress(deletedCount / total * 100); } this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index ecc439db69..cca7cdf9da 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -58,6 +58,10 @@ export class ExportBlockingProcessorService { let exportedCount = 0; let cursor: MiBlocking['id'] | null = null; + const total = await this.blockingsRepository.countBy({ + blockerId: user.id, + }); + while (true) { const blockings = await this.blockingsRepository.find({ where: { @@ -97,11 +101,7 @@ export class ExportBlockingProcessorService { exportedCount++; } - const total = await this.blockingsRepository.countBy({ - blockerId: user.id, - }); - - job.updateProgress(exportedCount / total); + job.updateProgress(exportedCount / total * 100); } stream.end(); diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts index 583ddbb745..486dc4c01f 100644 --- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -95,6 +95,10 @@ export class ExportClipsProcessorService { let exportedClipsCount = 0; let cursor: MiClip['id'] | null = null; + const total = await this.clipsRepository.countBy({ + userId: user.id, + }); + while (true) { const clips = await this.clipsRepository.find({ where: { @@ -126,11 +130,7 @@ export class ExportClipsProcessorService { exportedClipsCount++; } - const total = await this.clipsRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(exportedClipsCount / total); + job.updateProgress(exportedClipsCount / total * 100); } } diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index b81feece01..7918c8ccb5 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -78,6 +78,10 @@ export class ExportFavoritesProcessorService { let exportedFavoritesCount = 0; let cursor: MiNoteFavorite['id'] | null = null; + const total = await this.noteFavoritesRepository.countBy({ + userId: user.id, + }); + while (true) { const favorites = await this.noteFavoritesRepository.find({ where: { @@ -109,11 +113,7 @@ export class ExportFavoritesProcessorService { exportedFavoritesCount++; } - const total = await this.noteFavoritesRepository.countBy({ - userId: user.id, - }); - - job.updateProgress(exportedFavoritesCount / total); + job.updateProgress(exportedFavoritesCount / total * 100); } await write(']'); diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index f9867ade29..59448ccd34 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -58,6 +58,10 @@ export class ExportMutingProcessorService { let exportedCount = 0; let cursor: MiMuting['id'] | null = null; + const total = await this.mutingsRepository.countBy({ + muterId: user.id, + }); + while (true) { const mutes = await this.mutingsRepository.find({ where: { @@ -98,11 +102,7 @@ export class ExportMutingProcessorService { exportedCount++; } - const total = await this.mutingsRepository.countBy({ - muterId: user.id, - }); - - job.updateProgress(exportedCount / total); + job.updateProgress(exportedCount / total * 100); } stream.end(); diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 9e2b678219..3b68a4277a 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -37,6 +37,8 @@ class NoteStream extends ReadableStream> { let exportedNotesCount = 0; let cursor: MiNote['id'] | null = null; + const totalPromise = notesRepository.countBy({ userId }); + const serialize = ( note: MiNote, poll: MiPoll | null, @@ -88,8 +90,8 @@ class NoteStream extends ReadableStream> { exportedNotesCount++; } - const total = await notesRepository.countBy({ userId }); - job.updateProgress(exportedNotesCount / total); + const total = await totalPromise; + job.updateProgress(exportedNotesCount / total * 100); }, }); } diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 3ec5e5d3e6..53336a087d 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -129,7 +129,8 @@ export class SignupApiService { let ticket: MiRegistrationTicket | null = null; - if (this.meta.disableRegistration) { + // テスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test' && this.meta.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { reply.code(400); return; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 092d296bd3..5c4a58a6fc 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -168,6 +168,7 @@ export * as 'clips/update' from './endpoints/clips/update.js'; export * as 'drive' from './endpoints/drive.js'; export * as 'drive/files' from './endpoints/drive/files.js'; export * as 'drive/files/attached-notes' from './endpoints/drive/files/attached-notes.js'; +export * as 'drive/files/attached-chat-messages' from './endpoints/drive/files/attached-chat-messages.js'; export * as 'drive/files/check-existence' from './endpoints/drive/files/check-existence.js'; export * as 'drive/files/create' from './endpoints/drive/files/create.js'; export * as 'drive/files/delete' from './endpoints/drive/files/delete.js'; @@ -208,6 +209,7 @@ export * as 'flash/my-likes' from './endpoints/flash/my-likes.js'; export * as 'flash/show' from './endpoints/flash/show.js'; export * as 'flash/unlike' from './endpoints/flash/unlike.js'; export * as 'flash/update' from './endpoints/flash/update.js'; +export * as 'flash/search' from './endpoints/flash/search.js'; export * as 'following/create' from './endpoints/following/create.js'; export * as 'following/delete' from './endpoints/following/delete.js'; export * as 'following/invalidate' from './endpoints/following/invalidate.js'; @@ -307,6 +309,11 @@ export * as 'notes/clips' from './endpoints/notes/clips.js'; export * as 'notes/conversation' from './endpoints/notes/conversation.js'; export * as 'notes/create' from './endpoints/notes/create.js'; export * as 'notes/delete' from './endpoints/notes/delete.js'; +export * as 'notes/drafts/list' from './endpoints/notes/drafts/list.js'; +export * as 'notes/drafts/create' from './endpoints/notes/drafts/create.js'; +export * as 'notes/drafts/delete' from './endpoints/notes/drafts/delete.js'; +export * as 'notes/drafts/update' from './endpoints/notes/drafts/update.js'; +export * as 'notes/drafts/count' from './endpoints/notes/drafts/count.js'; export * as 'notes/favorites/create' from './endpoints/notes/favorites/create.js'; export * as 'notes/favorites/delete' from './endpoints/notes/favorites/delete.js'; export * as 'notes/featured' from './endpoints/notes/featured.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index 0dbfaae054..ff7133e73a 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -98,6 +98,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, state: { type: 'string', nullable: true, default: null }, reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, @@ -115,7 +117,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); switch (ps.state) { case 'resolved': query.andWhere('report.resolved = TRUE'); break; diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 6406709cda..4f897d98e4 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, publishing: { type: 'boolean', default: null, nullable: true }, }, required: [], @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.adsRepository.createQueryBuilder('ad'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); if (ps.publishing === true) { query.andWhere('ad.expiresAt > :now', { now: new Date() }).andWhere('ad.startsAt <= :now', { now: new Date() }); } else if (ps.publishing === false) { diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 7596bf44e3..81a788de2b 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -68,6 +68,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, userId: { type: 'string', format: 'misskey:id', nullable: true }, status: { type: 'string', enum: ['all', 'active', 'archived'], default: 'active' }, }, @@ -87,7 +89,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); if (ps.status === 'archived') { query.andWhere('announcement.isActive = false'); 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 d4d9a7235b..765bfd6766 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 @@ -71,6 +71,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts index 915d777e77..59b02482a2 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, userId: { type: 'string', format: 'misskey:id', nullable: true }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z0-9\/\-*]+$/.toString().slice(1, -1) }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, @@ -57,7 +59,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); if (ps.userId) { query.andWhere('file.userId = :userId', { userId: ps.userId }); 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 b44007962d..660aa55bf8 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 @@ -74,6 +74,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -89,7 +91,7 @@ export default class extends Endpoint { // eslint- private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { - const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); + const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); if (ps.host == null) { q.andWhere('emoji.host IS NOT NULL'); 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 4342e178cc..34d200455e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -68,6 +68,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -82,7 +84,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + const q = this.queryService.makePaginationQuery(this.emojisRepository.createQueryBuilder('emoji'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('emoji.host IS NULL'); let emojis: MiEmoji[]; diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index 198166bec2..63a8d513fd 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -49,6 +49,8 @@ export const paramDef = { roleId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['roleId'], @@ -76,7 +78,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRole); } - const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere(new Brackets(qb => { qb diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts index 58c5f1f60a..697fdf4210 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -9,6 +9,7 @@ import type { ModerationLogsRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogEntityService } from '@/core/entities/ModerationLogEntityService.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; export const meta = { tags: ['admin'], @@ -63,8 +64,11 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, type: { type: 'string', nullable: true }, userId: { type: 'string', format: 'misskey:id', nullable: true }, + search: { type: 'string', nullable: true }, }, required: [], } as const; @@ -79,19 +83,24 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.moderationLogsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.moderationLogsRepository.createQueryBuilder('log'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); if (ps.type != null) { - query.andWhere('report.type = :type', { type: ps.type }); + query.andWhere('log.type = :type', { type: ps.type }); } if (ps.userId != null) { - query.andWhere('report.userId = :userId', { userId: ps.userId }); + query.andWhere('log.userId = :userId', { userId: ps.userId }); } - const reports = await query.limit(ps.limit).getMany(); + if (ps.search != null) { + const escapedSearch = sqlLikeEscape(ps.search); + query.andWhere('log.info::text ILIKE :search', { search: `%${escapedSearch}%` }); + } - return await this.moderationLogEntityService.packMany(reports); + const logs = await query.limit(ps.limit).getMany(); + + return await this.moderationLogEntityService.packMany(logs); }); } } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index ff8dd73605..2ad1702f72 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -33,6 +33,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, isActive: { type: 'boolean', default: true }, }, required: [], @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private announcementEntityService: AnnouncementEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('announcement.isActive = :isActive', { isActive: ps.isActive }) .andWhere(new Brackets(qb => { if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts index 8431fa6b34..9885d32eda 100644 --- a/packages/backend/src/server/api/endpoints/blocking/list.ts +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.blockingsRepository.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.blockingsRepository.createQueryBuilder('blocking'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('blocking.blockerId = :meId', { meId: me.id }); const blockings = await query diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts index 294b5e4bc4..faceb03631 100644 --- a/packages/backend/src/server/api/endpoints/channels/followed.ts +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -33,6 +33,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, }, required: [], @@ -53,8 +55,8 @@ export default class extends Endpoint { // eslint- this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId, - null, - null, + ps.sinceDate, + ps.untilDate, 'followeeId', ) .andWhere({ followerId: me.id }); diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts index daab685f1b..d22ac18b4b 100644 --- a/packages/backend/src/server/api/endpoints/channels/owned.ts +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -33,6 +33,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, }, required: [], @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('channel.isArchived = FALSE') .andWhere({ userId: me.id }); diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts index ae32203603..7b6c4db91c 100644 --- a/packages/backend/src/server/api/endpoints/channels/search.ts +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -35,6 +35,8 @@ export const paramDef = { type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, }, required: ['query'], @@ -50,7 +52,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('channel.isArchived = FALSE'); if (ps.query !== '') { 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 index c0e344b889..dd598b5628 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts @@ -9,6 +9,7 @@ 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'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -42,6 +43,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, roomId: { type: 'string', format: 'misskey:id' }, }, required: ['roomId'], @@ -52,8 +55,12 @@ export default class extends Endpoint { // eslint- constructor( private chatEntityService: ChatEntityService, private chatService: ChatService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); const room = await this.chatService.findRoomById(ps.roomId); @@ -65,7 +72,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRoom); } - const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId); + const messages = await this.chatService.roomTimeline(room.id, ps.limit, sinceId, untilId); this.chatService.readRoomChatMessage(me.id, room.id); 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 index a057e2e088..52a13b2178 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts @@ -10,6 +10,7 @@ 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 { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -43,6 +44,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, userId: { type: 'string', format: 'misskey:id' }, }, required: ['userId'], @@ -54,8 +57,12 @@ export default class extends Endpoint { // eslint- private chatEntityService: ChatEntityService, private chatService: ChatService, private getterService: GetterService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); const other = await this.getterService.getUser(ps.userId).catch(err => { @@ -63,7 +70,7 @@ export default class extends Endpoint { // eslint- throw err; }); - const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId); + const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, sinceId, untilId); this.chatService.readUserChatMessage(me.id, other.id); 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 index 8a02d1c704..f4f91a7d8f 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts @@ -9,6 +9,7 @@ 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'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -37,6 +38,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, } as const; @@ -45,11 +48,15 @@ export default class extends Endpoint { // eslint- constructor( private chatEntityService: ChatEntityService, private chatService: ChatService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); - const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, sinceId, 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 index 0702ba086c..827bef731c 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts @@ -10,6 +10,7 @@ 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'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -44,6 +45,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['roomId'], } as const; @@ -53,8 +56,12 @@ export default class extends Endpoint { // eslint- constructor( private chatService: ChatService, private chatEntityService: ChatEntityService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); const room = await this.chatService.findMyRoomById(me.id, ps.roomId); @@ -62,7 +69,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRoom); } - const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, ps.sinceId, ps.untilId); + const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, sinceId, untilId); return this.chatEntityService.packRoomInvitations(invitations, me); }); } diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts index ba9242c762..b061da6d9c 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts @@ -9,6 +9,7 @@ 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'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -37,6 +38,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, } as const; @@ -45,11 +48,15 @@ export default class extends Endpoint { // eslint- constructor( private chatService: ChatService, private chatEntityService: ChatEntityService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); - const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); + const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, sinceId, untilId); return this.chatEntityService.packRoomMemberships(memberships, me, { populateUser: false, diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts index f5ffa21d32..aa3fb5168d 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts @@ -9,6 +9,7 @@ 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'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -43,6 +44,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['roomId'], } as const; @@ -52,8 +55,12 @@ export default class extends Endpoint { // eslint- constructor( private chatService: ChatService, private chatEntityService: ChatEntityService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); const room = await this.chatService.findRoomById(ps.roomId); @@ -65,7 +72,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRoom); } - const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId); + const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, sinceId, untilId); return this.chatEntityService.packRoomMemberships(memberships, me, { populateUser: true, diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts index accf7e1bee..40c954fcca 100644 --- a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts +++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts @@ -9,6 +9,7 @@ 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'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['chat'], @@ -37,6 +38,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, } as const; @@ -45,11 +48,15 @@ export default class extends Endpoint { // eslint- constructor( private chatEntityService: ChatEntityService, private chatService: ChatService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); + await this.chatService.checkChatAvailability(me.id, 'read'); - const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, sinceId, untilId); return this.chatEntityService.packRooms(rooms, me); }); } diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 4869ffd402..c4260fd87c 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -4,11 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, ClipsRepository, ClipNotesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,6 +46,9 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + search: { type: 'string', minLength: 1, maxLength: 100, nullable: true }, }, required: ['clipId'], } as const; @@ -76,7 +81,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchClip); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') @@ -95,6 +100,15 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' }); } + if (ps.search != null) { + for (const word of ps.search.trim().split(' ')) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.text ILIKE :search', { search: `%${sqlLikeEscape(word)}%` }); + qb.orWhere('note.cw ILIKE :search', { search: `%${sqlLikeEscape(word)}%` }); + })); + } + } + const notes = await query .limit(ps.limit) .getMany(); diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 10c521332d..303a4d4925 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] }, @@ -51,7 +53,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('file.userId = :userId', { userId: me.id }); if (ps.folderId) { diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-chat-messages.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-chat-messages.ts new file mode 100644 index 0000000000..5be477f468 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-chat-messages.ts @@ -0,0 +1,85 @@ +/* + * 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 { DriveFilesRepository, ChatMessagesRepository } from '@/models/_.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['drive', 'chat'], + + requireCredential: true, + + kind: 'read:drive', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '485ce26d-f5d2-4313-9783-e689d131eafb', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + private chatEntityService: ChatEntityService, + private queryService: QueryService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: await this.roleService.isModerator(me) ? undefined : me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); + query.andWhere('message.fileId = :fileId', { fileId: file.id }); + + const messages = await query.limit(ps.limit).getMany(); + + return await this.chatEntityService.packMessagesDetailed(messages, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index b86059b5e7..6bc8730a1e 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -9,8 +9,8 @@ import type { NotesRepository, DriveFilesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../../error.js'; export const meta = { tags: ['drive', 'notes'], @@ -45,6 +45,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, fileId: { type: 'string', format: 'misskey:id' }, }, @@ -75,7 +77,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchFile); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); query.andWhere(':file <@ note.fileIds', { file: [file.id] }); const notes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index 8c4848f8e1..23b3db60a8 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, required: [], @@ -49,7 +51,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFoldersRepository.createQueryBuilder('folder'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.driveFoldersRepository.createQueryBuilder('folder'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('folder.userId = :userId', { userId: me.id }); if (ps.folderId) { diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts index f7c1ed39b5..8bf83a9653 100644 --- a/packages/backend/src/server/api/endpoints/drive/stream.ts +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, type: { type: 'string', pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, }, required: [], @@ -49,7 +51,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.driveFilesRepository.createQueryBuilder('file'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('file.userId = :userId', { userId: me.id }); if (ps.type) { diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts index ce4dd13067..296bc7c5a8 100644 --- a/packages/backend/src/server/api/endpoints/federation/followers.ts +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -32,6 +32,8 @@ export const paramDef = { host: { type: 'string' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['host'], @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followeeHost = :host', { host: ps.host }); const followings = await query diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts index 1a793889c7..091bf442af 100644 --- a/packages/backend/src/server/api/endpoints/federation/following.ts +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -32,6 +32,8 @@ export const paramDef = { host: { type: 'string' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['host'], @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followerHost = :host', { host: ps.host }); const followings = await query diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts index 71b1aeb07b..c2d660f163 100644 --- a/packages/backend/src/server/api/endpoints/federation/users.ts +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -32,6 +32,8 @@ export const paramDef = { host: { type: 'string' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['host'], @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.usersRepository.createQueryBuilder('user'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.usersRepository.createQueryBuilder('user'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('user.host = :host', { host: ps.host }); const users = await query diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts index 755cc5acfc..ff9d6c3264 100644 --- a/packages/backend/src/server/api/endpoints/flash/my-likes.ts +++ b/packages/backend/src/server/api/endpoints/flash/my-likes.ts @@ -5,10 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { FlashLikesRepository } from '@/models/_.js'; -import { QueryService } from '@/core/QueryService.js'; import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js'; import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; export const meta = { tags: ['account', 'flash'], @@ -44,6 +43,9 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + search: { type: 'string', minLength: 1, maxLength: 100, nullable: true }, }, required: [], } as const; @@ -51,20 +53,18 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.flashLikesRepository) - private flashLikesRepository: FlashLikesRepository, - private flashLikeEntityService: FlashLikeEntityService, - private queryService: QueryService, + private flashService: FlashService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) - .andWhere('like.userId = :meId', { meId: me.id }) - .leftJoinAndSelect('like.flash', 'flash'); - - const likes = await query - .limit(ps.limit) - .getMany(); + const likes = await this.flashService.myLikes(me.id, { + sinceId: ps.sinceId, + untilId: ps.untilId, + sinceDate: ps.sinceDate, + untilDate: ps.untilDate, + limit: ps.limit, + search: ps.search, + }); return this.flashLikeEntityService.packMany(likes, me); }); diff --git a/packages/backend/src/server/api/endpoints/flash/my.ts b/packages/backend/src/server/api/endpoints/flash/my.ts index 5746096232..6ec623b719 100644 --- a/packages/backend/src/server/api/endpoints/flash/my.ts +++ b/packages/backend/src/server/api/endpoints/flash/my.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('flash.userId = :meId', { meId: me.id }); const flashs = await query diff --git a/packages/backend/src/server/api/endpoints/flash/search.ts b/packages/backend/src/server/api/endpoints/flash/search.ts new file mode 100644 index 0000000000..36948bb7b4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/search.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 { FlashEntityService } from '@/core/entities/FlashEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; + +export const meta = { + tags: ['flash'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Flash', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 100 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, + }, + required: ['query'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private flashService: FlashService, + private flashEntityService: FlashEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.flashService.search(ps.query, { + sinceId: ps.sinceId, + untilId: ps.untilId, + sinceDate: ps.sinceDate, + untilDate: ps.untilDate, + limit: ps.limit, + }); + + return await this.flashEntityService.packMany(result, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts index fa59e38976..cf614e433d 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/list.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -49,6 +49,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: [], @@ -64,7 +66,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('request.followeeId = :meId', { meId: me.id }); const requests = await query diff --git a/packages/backend/src/server/api/endpoints/following/requests/sent.ts b/packages/backend/src/server/api/endpoints/following/requests/sent.ts index 6325f01bb8..2b3cc35f03 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/sent.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/sent.ts @@ -49,6 +49,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: [], @@ -64,7 +66,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('request.followerId = :meId', { meId: me.id }); const requests = await query diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts index d398418ab4..846b903bdb 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -30,6 +30,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -44,7 +46,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .innerJoinAndSelect('post.user', 'user'); const posts = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts index 3558035eca..00277282ba 100644 --- a/packages/backend/src/server/api/endpoints/i/favorites.ts +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.noteFavoritesRepository.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.noteFavoritesRepository.createQueryBuilder('favorite'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('favorite.userId = :meId', { meId: me.id }) .leftJoinAndSelect('favorite.note', 'note'); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts index d492585ffa..9913ed7489 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -45,6 +45,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -59,7 +61,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.galleryLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('like.userId = :meId', { meId: me.id }) .leftJoinAndSelect('like.post', 'post'); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts index 73a6fcc98b..c9d17fc35c 100644 --- a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('post.userId = :meId', { meId: me.id }); const posts = await query 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 b9c41b057d..f933eaab00 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -49,6 +49,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { @@ -64,15 +66,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { const EXTRA_LIMIT = 100; + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); // includeTypes が空の場合はクエリしない if (ps.includeTypes && ps.includeTypes.length === 0) { @@ -87,8 +88,8 @@ export default class extends Endpoint { // eslint- const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][]; const notifications = await this.notificationService.getNotifications(me.id, { - sinceId: ps.sinceId, - untilId: ps.untilId, + sinceId: sinceId, + untilId: untilId, limit: ps.limit, includeTypes, excludeTypes, diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index f5a48b2f69..158cc9fd73 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -44,6 +44,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { @@ -59,17 +61,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); + // includeTypes が空の場合はクエリしない if (ps.includeTypes && ps.includeTypes.length === 0) { return []; @@ -83,8 +82,8 @@ export default class extends Endpoint { // eslint- const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const notifications = await this.notificationService.getNotifications(me.id, { - sinceId: ps.sinceId, - untilId: ps.untilId, + sinceId: sinceId, + untilId: untilId, limit: ps.limit, includeTypes, excludeTypes, diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts index d4c09426a7..d62dd819f8 100644 --- a/packages/backend/src/server/api/endpoints/i/page-likes.ts +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -44,6 +44,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -58,7 +60,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.pageLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.pageLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('like.userId = :meId', { meId: me.id }) .leftJoinAndSelect('like.page', 'page'); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts index 1b6359a633..385842539e 100644 --- a/packages/backend/src/server/api/endpoints/i/pages.ts +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('page.userId = :meId', { meId: me.id }); const pages = await query diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts index 76ad0bbe21..c104dfdcdd 100644 --- a/packages/backend/src/server/api/endpoints/i/signin-history.ts +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -31,6 +31,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -45,7 +47,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.signinsRepository.createQueryBuilder('signin'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.signinsRepository.createQueryBuilder('signin'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('signin.userId = :meId', { meId: me.id }); const history = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts index a99974a91e..71f8f579a5 100644 --- a/packages/backend/src/server/api/endpoints/invite/list.ts +++ b/packages/backend/src/server/api/endpoints/invite/list.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('ticket.createdById = :meId', { meId: me.id }) .leftJoinAndSelect('ticket.createdBy', 'createdBy') .leftJoinAndSelect('ticket.usedBy', 'usedBy'); diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts index 23204f2829..7762556bee 100644 --- a/packages/backend/src/server/api/endpoints/mute/list.ts +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.mutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.mutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('muting.muterId = :meId', { meId: me.id }); const mutings = await query diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index 9938322a2a..0800828a87 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -35,6 +35,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -49,7 +51,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.visibility = \'public\'') .andWhere('note.localOnly = FALSE') .innerJoinAndSelect('note.user', 'user') diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index d457ad1220..e9d56e2892 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['noteId'], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere(new Brackets(qb => { qb .where('note.replyId = :noteId', { noteId: ps.noteId }) diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/count.ts b/packages/backend/src/server/api/endpoints/notes/drafts/count.ts new file mode 100644 index 0000000000..002a545d32 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/drafts/count.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 type { NoteDraftsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['notes', 'drafts'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'read:account', + + res: { + type: 'number', + optional: false, nullable: false, + description: 'The number of drafts', + }, + + errors: { + }, +} 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.noteDraftsRepository) + private noteDraftsRepository: NoteDraftsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const count = await this.noteDraftsRepository.createQueryBuilder('drafts') + .where('drafts.userId = :meId', { meId: me.id }) + .getCount(); + + return count; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts new file mode 100644 index 0000000000..1c28ec22d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteDraftService } from '@/core/NoteDraftService.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { ApiError } from '@/server/api/error.js'; +import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +export const meta = { + tags: ['notes', 'drafts'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:account', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + createdDraft: { + type: 'object', + optional: false, nullable: false, + ref: 'NoteDraft', + }, + }, + }, + + errors: { + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4', + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', + }, + + cannotRenoteDueToVisibility: { + message: 'You can not Renote due to target visibility.', + code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY', + id: 'be9529e9-fe72-4de0-ae43-0b363c4938af', + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c', + }, + + cannotReplyToInvisibleNote: { + message: 'You cannot reply to an invisible Note.', + code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE', + id: 'b98980fa-3780-406c-a935-b6d0eeee10d1', + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', + }, + + cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: { + message: 'You cannot reply to a specified visibility note with extended visibility.', + code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', + id: 'ed940410-535c-4d5e-bfa3-af798671e93c', + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb', + }, + + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', + }, + + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, + + containsProhibitedWords: { + message: 'Cannot post because it contains prohibited words.', + code: 'CONTAINS_PROHIBITED_WORDS', + id: 'aa6e01d3-a85c-669d-758a-76aab43af334', + }, + + containsTooManyMentions: { + message: 'Cannot post because it exceeds the allowed number of mentions.', + code: 'CONTAINS_TOO_MANY_MENTIONS', + id: '4de0363a-3046-481b-9b0f-feff3e211025', + }, + + tooManyDrafts: { + message: 'You cannot create drafts any more.', + code: 'TOO_MANY_DRAFTS', + id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', + }, + + cannotRenoteToExternal: { + message: 'Cannot Renote to External.', + code: 'CANNOT_RENOTE_TO_EXTERNAL', + id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', + }, + }, + + limit: { + duration: ms('1hour'), + max: 300, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibleUserIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, + hashtag: { type: 'string', nullable: true, maxLength: 200 }, + localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + channelId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + text: { + type: 'string', + minLength: 0, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: true, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 0, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private noteDraftService: NoteDraftService, + private noteDraftEntityService: NoteDraftEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const draft = await this.noteDraftService.create(me, { + fileIds: ps.fileIds, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + expiredAfter: ps.poll.expiredAfter ?? null, + } : undefined, + text: ps.text ?? null, + replyId: ps.replyId ?? undefined, + renoteId: ps.renoteId ?? undefined, + cw: ps.cw ?? null, + ...(ps.hashtag ? { hashtag: ps.hashtag } : {}), + localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUserIds: ps.visibleUserIds ?? [], + channelId: ps.channelId ?? undefined, + }).catch((err) => { + if (err instanceof IdentifiableError) { + switch (err.id) { + case '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8': + throw new ApiError(meta.errors.tooManyDrafts); + case '04da457d-b083-4055-9082-955525eda5a5': + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + case 'b6992544-63e7-67f0-fa7f-32444b1b5306': + throw new ApiError(meta.errors.noSuchFile); + case '64929870-2540-4d11-af41-3b484d78c956': + throw new ApiError(meta.errors.noSuchRenoteTarget); + case '76cc5583-5a14-4ad3-8717-0298507e32db': + throw new ApiError(meta.errors.cannotReRenote); + case '075ca298-e6e7-485a-b570-51a128bb5168': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '81eb8188-aea1-4e35-9a8f-3334a3be9855': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case '6815399a-6f13-4069-b60d-ed5156249d12': + throw new ApiError(meta.errors.noSuchChannel); + case 'ed1952ac-2d26-4957-8b30-2deda76bedf7': + throw new ApiError(meta.errors.cannotRenoteToExternal); + case 'c4721841-22fc-4bb7-ad3d-897ef1d375b5': + throw new ApiError(meta.errors.noSuchReplyTarget); + case 'e6c10b57-2c09-4da3-bd4d-eda05d51d140': + throw new ApiError(meta.errors.cannotReplyToPureRenote); + case '593c323c-6b6a-4501-a25c-2f36bd2a93d6': + throw new ApiError(meta.errors.cannotReplyToInvisibleNote); + case '215dbc76-336c-4d2a-9605-95766ba7dab0': + throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); + default: + throw err; + } + } + throw err; + }); + + const createdDraft = await this.noteDraftEntityService.pack(draft, me); + + return { + createdDraft, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/delete.ts b/packages/backend/src/server/api/endpoints/notes/drafts/delete.ts new file mode 100644 index 0000000000..6c41145c18 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/drafts/delete.ts @@ -0,0 +1,61 @@ +/* + * 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 { NoteDraftService } from '@/core/NoteDraftService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes', 'drafts'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:account', + + errors: { + noSuchNoteDraft: { + message: 'No such note draft.', + code: 'NO_SUCH_NOTE_DRAFT', + id: '49cd6b9d-848e-41ee-b0b9-adaca711a6b1', + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + draftId: { type: 'string', nullable: false, format: 'misskey:id' }, + }, + required: ['draftId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private noteDraftService: NoteDraftService, + ) { + super(meta, paramDef, async (ps, me) => { + const draft = await this.noteDraftService.get(me, ps.draftId); + if (draft == null) { + throw new ApiError(meta.errors.noSuchNoteDraft); + } + + if (draft.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.noteDraftService.delete(me, draft.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts new file mode 100644 index 0000000000..f24f9b8fb2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts @@ -0,0 +1,68 @@ +/* + * 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 { MiNoteDraft, NoteDraftsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js'; + +export const meta = { + tags: ['notes', 'drafts'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'read:account', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'NoteDraft', + }, + }, + + 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' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteDraftsRepository) + private noteDraftsRepository: NoteDraftsRepository, + + private queryService: QueryService, + private noteDraftEntityService: NoteDraftEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('drafts.userId = :meId', { meId: me.id }); + + const drafts = await query + .limit(ps.limit) + .getMany(); + + return await this.noteDraftEntityService.packMany(drafts, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts new file mode 100644 index 0000000000..ee221fb765 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts @@ -0,0 +1,302 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteDraftService } from '@/core/NoteDraftService.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes', 'drafts'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:account', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + updatedDraft: { + type: 'object', + optional: false, nullable: false, + ref: 'NoteDraft', + }, + }, + }, + + errors: { + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4', + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a', + }, + + cannotRenoteDueToVisibility: { + message: 'You can not Renote due to target visibility.', + code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY', + id: 'be9529e9-fe72-4de0-ae43-0b363c4938af', + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c', + }, + + cannotReplyToInvisibleNote: { + message: 'You cannot reply to an invisible Note.', + code: 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE', + id: 'b98980fa-3780-406c-a935-b6d0eeee10d1', + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', + }, + + cannotReplyToSpecifiedNoteWithExtendedVisibility: { + message: 'You cannot reply to a specified visibility note with extended visibility.', + code: 'CANNOT_REPLY_TO_SPECIFIED_NOTE_WITH_EXTENDED_VISIBILITY', + id: 'ed940410-535c-4d5e-bfa3-af798671e93c', + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5', + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb', + }, + + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3', + }, + + noSuchFile: { + message: 'Some files are not found.', + code: 'NO_SUCH_FILE', + id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', + }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, + + containsProhibitedWords: { + message: 'Cannot post because it contains prohibited words.', + code: 'CONTAINS_PROHIBITED_WORDS', + id: 'aa6e01d3-a85c-669d-758a-76aab43af334', + }, + + containsTooManyMentions: { + message: 'Cannot post because it exceeds the allowed number of mentions.', + code: 'CONTAINS_TOO_MANY_MENTIONS', + id: '4de0363a-3046-481b-9b0f-feff3e211025', + }, + + noSuchNoteDraft: { + message: 'No such note draft.', + code: 'NO_SUCH_NOTE_DRAFT', + id: '49cd6b9d-848e-41ee-b0b9-adaca711a6b1', + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', + }, + + noSuchRenote: { + message: 'No such renote.', + code: 'NO_SUCH_RENOTE', + id: '64929870-2540-4d11-af41-3b484d78c956', + }, + + cannotRenote: { + message: 'Cannot renote.', + code: 'CANNOT_RENOTE', + id: '76cc5583-5a14-4ad3-8717-0298507e32db', + }, + + cannotRenoteToExternal: { + message: 'Cannot Renote to External.', + code: 'CANNOT_RENOTE_TO_EXTERNAL', + id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', + }, + + noSuchReply: { + message: 'No such reply.', + code: 'NO_SUCH_REPLY', + id: 'c4721841-22fc-4bb7-ad3d-897ef1d375b5', + }, + + cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: { + message: 'You cannot reply to a specified visibility note with extended visibility.', + code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', + id: '215dbc76-336c-4d2a-9605-95766ba7dab0', + }, + }, + + limit: { + duration: ms('1hour'), + max: 300, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + draftId: { type: 'string', nullable: false, format: 'misskey:id' }, + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibleUserIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, + hashtag: { type: 'string', nullable: true, maxLength: 200 }, + localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + replyId: { type: 'string', format: 'misskey:id', nullable: true }, + renoteId: { type: 'string', format: 'misskey:id', nullable: true }, + channelId: { type: 'string', format: 'misskey:id', nullable: true }, + + // anyOf内にバリデーションを書いても最初の一つしかチェックされない + // See https://github.com/misskey-dev/misskey/pull/10082 + text: { + type: 'string', + minLength: 0, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: true, + }, + fileIds: { + type: 'array', + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: 'string', format: 'misskey:id' }, + }, + poll: { + type: 'object', + nullable: true, + properties: { + choices: { + type: 'array', + uniqueItems: true, + minItems: 0, + maxItems: 10, + items: { type: 'string', minLength: 1, maxLength: 50 }, + }, + multiple: { type: 'boolean' }, + expiresAt: { type: 'integer', nullable: true }, + expiredAfter: { type: 'integer', nullable: true, minimum: 1 }, + }, + required: ['choices'], + }, + }, + required: ['draftId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private noteDraftService: NoteDraftService, + private noteDraftEntityService: NoteDraftEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const draft = await this.noteDraftService.update(me, ps.draftId, { + fileIds: ps.fileIds, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple ?? false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + expiredAfter: ps.poll.expiredAfter ?? null, + } : undefined, + text: ps.text ?? null, + replyId: ps.replyId ?? undefined, + renoteId: ps.renoteId ?? undefined, + cw: ps.cw ?? null, + ...(ps.hashtag ? { hashtag: ps.hashtag } : {}), + localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, + visibility: ps.visibility, + visibleUserIds: ps.visibleUserIds ?? [], + channelId: ps.channelId ?? undefined, + }).catch((err) => { + if (err instanceof IdentifiableError) { + switch (err.id) { + case '49cd6b9d-848e-41ee-b0b9-adaca711a6b1': + throw new ApiError(meta.errors.noSuchNoteDraft); + case '04da457d-b083-4055-9082-955525eda5a5': + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + case 'b6992544-63e7-67f0-fa7f-32444b1b5306': + throw new ApiError(meta.errors.noSuchFile); + case '64929870-2540-4d11-af41-3b484d78c956': + throw new ApiError(meta.errors.noSuchRenote); + case '76cc5583-5a14-4ad3-8717-0298507e32db': + throw new ApiError(meta.errors.cannotRenote); + case '075ca298-e6e7-485a-b570-51a128bb5168': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '81eb8188-aea1-4e35-9a8f-3334a3be9855': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case '6815399a-6f13-4069-b60d-ed5156249d12': + throw new ApiError(meta.errors.noSuchChannel); + case 'ed1952ac-2d26-4957-8b30-2deda76bedf7': + throw new ApiError(meta.errors.cannotRenoteToExternal); + case 'c4721841-22fc-4bb7-ad3d-897ef1d375b5': + throw new ApiError(meta.errors.noSuchReply); + case 'e6c10b57-2c09-4da3-bd4d-eda05d51d140': + throw new ApiError(meta.errors.cannotReplyToPureRenote); + case '593c323c-6b6a-4501-a25c-2f36bd2a93d6': + throw new ApiError(meta.errors.cannotReplyToInvisibleNote); + case '215dbc76-336c-4d2a-9605-95766ba7dab0': + throw new ApiError(meta.errors.cannotReplyToSpecifiedNoteWithExtendedVisibility); + case 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4': + throw new ApiError(meta.errors.noSuchRenoteTarget); + case 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a': + throw new ApiError(meta.errors.cannotReRenote); + case '749ee0f6-d3da-459a-bf02-282e2da4292c': + throw new ApiError(meta.errors.noSuchReplyTarget); + case '33510210-8452-094c-6227-4a6c05d99f00': + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + case 'aa6e01d3-a85c-669d-758a-76aab43af334': + throw new ApiError(meta.errors.containsProhibitedWords); + case '4de0363a-3046-481b-9b0f-feff3e211025': + throw new ApiError(meta.errors.containsTooManyMentions); + default: + throw err; + } + } + throw err; + }); + + const updatedDraft = await this.noteDraftEntityService.pack(draft, me); + + return { + updatedDraft, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 7ffc349db5..05ffdc1f97 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -35,6 +35,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, visibility: { type: 'string' }, }, required: [], @@ -57,7 +59,7 @@ export default class extends Endpoint { // eslint- .select('following.followeeId') .where('following.followerId = :followerId', { followerId: me.id }); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere(new Brackets(qb => { qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる .where(':meIdAsList <@ note.mentions') diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 97b12ab7f7..e5e15fd4b3 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -47,6 +47,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['noteId'], } as const; @@ -61,7 +63,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.noteId = :noteId', { noteId: ps.noteId }) .leftJoinAndSelect('reaction.user', 'user') .leftJoinAndSelect('reaction.note', 'note'); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index fa2306c1bf..b294794de6 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -43,6 +43,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['noteId'], } as const; @@ -63,7 +65,7 @@ export default class extends Endpoint { // eslint- throw err; }); - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 9626947480..d567d7c64a 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -32,6 +32,8 @@ export const paramDef = { noteId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['noteId'], @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') 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 51255f0bbf..2cd6610cf6 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 @@ -72,6 +72,8 @@ export const paramDef = { poll: { type: 'boolean', nullable: true, default: null }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, }, @@ -88,7 +90,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 3fe19806e3..ab1bd934fb 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { SearchService } from '@/core/SearchService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -40,6 +41,8 @@ export const paramDef = { query: { type: 'string' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, host: { @@ -60,8 +63,12 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private searchService: SearchService, private roleService: RoleService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); + const policies = await this.roleService.getUserPolicies(me ? me.id : null); if (!policies.canSearchNotes) { throw new ApiError(meta.errors.unavailable); @@ -72,8 +79,8 @@ export default class extends Endpoint { // eslint- channelId: ps.channelId, host: ps.host, }, { - untilId: ps.untilId, - sinceId: ps.sinceId, + untilId: untilId, + sinceId: sinceId, limit: ps.limit, }); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts index 3be01f989a..adf5aa76bf 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/list.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -34,6 +34,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: [], } as const; @@ -48,7 +50,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('muting.muterId = :meId', { meId: me.id }); const mutings = await query diff --git a/packages/backend/src/server/api/endpoints/reversi/games.ts b/packages/backend/src/server/api/endpoints/reversi/games.ts index 6b06068727..22f3a0617b 100644 --- a/packages/backend/src/server/api/endpoints/reversi/games.ts +++ b/packages/backend/src/server/api/endpoints/reversi/games.ts @@ -27,6 +27,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, my: { type: 'boolean', default: false }, }, required: [], @@ -42,7 +44,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.reversiGamesRepository.createQueryBuilder('game'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .innerJoinAndSelect('game.user1', 'user1') .innerJoinAndSelect('game.user2', 'user2'); diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index 48d350af59..c7ec7910d6 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -51,6 +51,8 @@ export const paramDef = { roleId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: ['roleId'], @@ -79,7 +81,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchRole); } - const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('assign.roleId = :roleId', { roleId: role.id }) .andWhere(new Brackets(qb => { qb diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index 7f7d2ea8cc..fb5a2391f2 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -33,6 +33,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['userId'], } as const; @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.clipsRepository.createQueryBuilder('clip'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.clipsRepository.createQueryBuilder('clip'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('clip.userId = :userId', { userId: ps.userId }) .andWhere('clip.isPublic = true'); diff --git a/packages/backend/src/server/api/endpoints/users/flashs.ts b/packages/backend/src/server/api/endpoints/users/flashs.ts index e5ea450215..2ef514bebf 100644 --- a/packages/backend/src/server/api/endpoints/users/flashs.ts +++ b/packages/backend/src/server/api/endpoints/users/flashs.ts @@ -33,11 +33,12 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['userId'], } as const; - -// eslint-disable-next-line import/no-default-export + @Injectable() export default class extends Endpoint { constructor( @@ -48,7 +49,7 @@ export default class extends Endpoint { private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('flash.userId = :userId', { userId: ps.userId }) .andWhere('flash.visibility = \'public\''); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index bb8d4c49e9..84c4c80d01 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -76,6 +76,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, }, @@ -132,7 +134,7 @@ export default class extends Endpoint { // eslint- } } - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followeeId = :userId', { userId: user.id }) .innerJoinAndSelect('following.follower', 'follower'); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 1fc87151b2..047f9a053b 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -83,6 +83,8 @@ export const paramDef = { properties: { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, birthday: { ...birthdaySchema, nullable: true }, }, @@ -140,7 +142,7 @@ export default class extends Endpoint { // eslint- } } - const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followerId = :userId', { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts index 553886374c..55e885e3c3 100644 --- a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -33,6 +33,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['userId'], } as const; @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.galleryPostsRepository.createQueryBuilder('post'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('post.userId = :userId', { userId: ps.userId }); const posts = await query diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts index 6d6e8d34ea..a841c4b94d 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts @@ -64,6 +64,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['listId'], } as const; @@ -94,7 +96,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchList); } - const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('membership.userListId = :userListId', { userListId: userList.id }) .innerJoinAndSelect('membership.user', 'user'); diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts index bb7de0e0b5..1d7499d4be 100644 --- a/packages/backend/src/server/api/endpoints/users/pages.ts +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -33,6 +33,8 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, }, required: ['userId'], } as const; @@ -47,7 +49,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId) + const query = this.queryService.makePaginationQuery(this.pagesRepository.createQueryBuilder('page'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('page.userId = :userId', { userId: ps.userId }) .andWhere('page.visibility = \'public\''); 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 7139715293..c07d100a99 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 @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['admin'], @@ -65,6 +66,8 @@ export const paramDef = { }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, page: { type: 'integer' }, sortKeys: { @@ -84,8 +87,12 @@ export default class extends Endpoint { // eslint- constructor( private customEmojiService: CustomEmojiService, private emojiEntityService: EmojiEntityService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); + const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); + const q = ps.query; const result = await this.customEmojiService.fetchEmojis( { @@ -105,8 +112,8 @@ export default class extends Endpoint { // eslint- hostType: q?.hostType, roleIds: q?.roleIds, }, - sinceId: ps.sinceId, - untilId: ps.untilId, + sinceId: sinceId, + untilId: untilId, }, { limit: ps.limit, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 5d5f1e3b71..b20f2a2179 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -54,6 +54,8 @@ export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; +export const noteReactionAcceptances = ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null] as const; + export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index e4483acd7a..3d2ed21337 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -74,6 +74,10 @@ services: source: ../../../pnpm-workspace.yaml target: /misskey/pnpm-workspace.yaml read_only: true + - type: bind + source: ../../../patches + target: /misskey/patches + read_only: true - type: bind source: ./certificates/rootCA.crt target: /usr/local/share/ca-certificates/rootCA.crt diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index bd0ac15a31..330cc33854 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -74,6 +74,10 @@ services: source: ../../../pnpm-workspace.yaml target: /misskey/pnpm-workspace.yaml read_only: true + - type: bind + source: ../../../patches + target: /misskey/patches + read_only: true - type: bind source: ./certificates/rootCA.crt target: /usr/local/share/ca-certificates/rootCA.crt @@ -118,6 +122,10 @@ services: source: ../../../pnpm-workspace.yaml target: /misskey/pnpm-workspace.yaml read_only: true + - type: bind + source: ../../../patches + target: /misskey/patches + read_only: true working_dir: /misskey command: > bash -c " diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts index 9e4bad147b..91c2286ff6 100644 --- a/packages/backend/test/unit/FlashService.ts +++ b/packages/backend/test/unit/FlashService.ts @@ -7,9 +7,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { FlashService } from '@/core/FlashService.js'; import { IdService } from '@/core/IdService.js'; -import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { FlashLikesRepository, FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; describe('FlashService', () => { let app: TestingModule; @@ -18,6 +19,7 @@ describe('FlashService', () => { // -------------------------------------------------------------------------------------- let flashsRepository: FlashsRepository; + let flashLikesRepository: FlashLikesRepository; let usersRepository: UsersRepository; let userProfilesRepository: UserProfilesRepository; let idService: IdService; @@ -65,6 +67,7 @@ describe('FlashService', () => { app = await Test.createTestingModule({ imports: [ GlobalModule, + CoreModule, ], providers: [ FlashService, @@ -75,6 +78,7 @@ describe('FlashService', () => { service = app.get(FlashService); flashsRepository = app.get(DI.flashsRepository); + flashLikesRepository = app.get(DI.flashLikesRepository); usersRepository = app.get(DI.usersRepository); userProfilesRepository = app.get(DI.userProfilesRepository); idService = app.get(IdService); @@ -88,6 +92,7 @@ describe('FlashService', () => { await usersRepository.createQueryBuilder().delete().execute(); await userProfilesRepository.createQueryBuilder().delete().execute(); await flashsRepository.createQueryBuilder().delete().execute(); + await flashLikesRepository.createQueryBuilder().delete().execute(); }); afterAll(async () => { diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts index 1957f4544c..3cfb4ff3f8 100644 --- a/packages/backend/test/unit/ReactionService.ts +++ b/packages/backend/test/unit/ReactionService.ts @@ -21,73 +21,73 @@ describe('ReactionService', () => { }); describe('normalize', () => { - test('絵文字リアクションはそのまま', async () => { - assert.strictEqual(await reactionService.normalize('👍'), '👍'); - assert.strictEqual(await reactionService.normalize('🍅'), '🍅'); + test('絵文字リアクションはそのまま', () => { + assert.strictEqual(reactionService.normalize('👍'), '👍'); + assert.strictEqual(reactionService.normalize('🍅'), '🍅'); }); - test('既存のリアクションは絵文字化する pudding', async () => { - assert.strictEqual(await reactionService.normalize('pudding'), '🍮'); + test('既存のリアクションは絵文字化する pudding', () => { + assert.strictEqual(reactionService.normalize('pudding'), '🍮'); }); - test('既存のリアクションは絵文字化する like', async () => { - assert.strictEqual(await reactionService.normalize('like'), '👍'); + test('既存のリアクションは絵文字化する like', () => { + assert.strictEqual(reactionService.normalize('like'), '👍'); }); - test('既存のリアクションは絵文字化する love', async () => { - assert.strictEqual(await reactionService.normalize('love'), '❤'); + test('既存のリアクションは絵文字化する love', () => { + assert.strictEqual(reactionService.normalize('love'), '❤'); }); - test('既存のリアクションは絵文字化する laugh', async () => { - assert.strictEqual(await reactionService.normalize('laugh'), '😆'); + test('既存のリアクションは絵文字化する laugh', () => { + assert.strictEqual(reactionService.normalize('laugh'), '😆'); }); - test('既存のリアクションは絵文字化する hmm', async () => { - assert.strictEqual(await reactionService.normalize('hmm'), '🤔'); + test('既存のリアクションは絵文字化する hmm', () => { + assert.strictEqual(reactionService.normalize('hmm'), '🤔'); }); - test('既存のリアクションは絵文字化する surprise', async () => { - assert.strictEqual(await reactionService.normalize('surprise'), '😮'); + test('既存のリアクションは絵文字化する surprise', () => { + assert.strictEqual(reactionService.normalize('surprise'), '😮'); }); - test('既存のリアクションは絵文字化する congrats', async () => { - assert.strictEqual(await reactionService.normalize('congrats'), '🎉'); + test('既存のリアクションは絵文字化する congrats', () => { + assert.strictEqual(reactionService.normalize('congrats'), '🎉'); }); - test('既存のリアクションは絵文字化する angry', async () => { - assert.strictEqual(await reactionService.normalize('angry'), '💢'); + test('既存のリアクションは絵文字化する angry', () => { + assert.strictEqual(reactionService.normalize('angry'), '💢'); }); - test('既存のリアクションは絵文字化する confused', async () => { - assert.strictEqual(await reactionService.normalize('confused'), '😥'); + test('既存のリアクションは絵文字化する confused', () => { + assert.strictEqual(reactionService.normalize('confused'), '😥'); }); - test('既存のリアクションは絵文字化する rip', async () => { - assert.strictEqual(await reactionService.normalize('rip'), '😇'); + test('既存のリアクションは絵文字化する rip', () => { + assert.strictEqual(reactionService.normalize('rip'), '😇'); }); - test('既存のリアクションは絵文字化する star', async () => { - assert.strictEqual(await reactionService.normalize('star'), '⭐'); + test('既存のリアクションは絵文字化する star', () => { + assert.strictEqual(reactionService.normalize('star'), '⭐'); }); - test('異体字セレクタ除去', async () => { - assert.strictEqual(await reactionService.normalize('㊗️'), '㊗'); + test('異体字セレクタ除去', () => { + assert.strictEqual(reactionService.normalize('㊗️'), '㊗'); }); - test('異体字セレクタ除去 必要なし', async () => { - assert.strictEqual(await reactionService.normalize('㊗'), '㊗'); + test('異体字セレクタ除去 必要なし', () => { + assert.strictEqual(reactionService.normalize('㊗'), '㊗'); }); - test('fallback - null', async () => { - assert.strictEqual(await reactionService.normalize(null), '❤'); + test('fallback - null', () => { + assert.strictEqual(reactionService.normalize(null), '❤'); }); - test('fallback - empty', async () => { - assert.strictEqual(await reactionService.normalize(''), '❤'); + test('fallback - empty', () => { + assert.strictEqual(reactionService.normalize(''), '❤'); }); - test('fallback - unknown', async () => { - assert.strictEqual(await reactionService.normalize('unknown'), '❤'); + test('fallback - unknown', () => { + assert.strictEqual(reactionService.normalize('unknown'), '❤'); }); }); diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 2aef311e2e..179d811e77 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -41,6 +41,7 @@ export default [ }, }, rules: { + '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 5e73c26daf..bae5b0ebd3 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -13,10 +13,10 @@ "@discordapp/twemoji": "15.1.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.2", - "@rollup/pluginutils": "5.1.4", + "@rollup/pluginutils": "5.2.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.4", - "@vue/compiler-sfc": "3.5.16", + "@vue/compiler-sfc": "3.5.17", "astring": "1.9.0", "buraha": "0.0.1", "estree-walker": "3.0.3", @@ -26,46 +26,46 @@ "mfm-js": "0.24.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.42.0", + "rollup": "4.45.1", "sass": "1.89.2", - "shiki": "3.6.0", + "shiki": "3.8.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.16", "tsconfig-paths": "4.2.0", "typescript": "5.8.3", "uuid": "11.1.0", "vite": "6.3.5", - "vue": "3.5.16" + "vue": "3.5.17" }, "devDependencies": { - "@misskey-dev/summaly": "5.2.1", + "@misskey-dev/summaly": "5.2.2", "@tabler/icons-webfont": "3.34.0", "@testing-library/vue": "8.1.0", "@types/estree": "1.0.8", "@types/micromatch": "4.0.9", - "@types/node": "22.15.31", + "@types/node": "22.16.4", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", - "@vitest/coverage-v8": "3.2.3", - "@vue/runtime-core": "3.5.16", + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "@vitest/coverage-v8": "3.2.4", + "@vue/runtime-core": "3.5.17", "acorn": "8.15.0", "cross-env": "7.0.3", - "eslint-plugin-import": "2.31.0", - "eslint-plugin-vue": "10.2.0", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-vue": "10.3.0", "fast-glob": "3.3.3", "happy-dom": "17.6.3", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.10.2", + "msw": "2.10.4", "nodemon": "3.1.10", - "prettier": "3.5.3", + "prettier": "3.6.2", "start-server-and-test": "2.0.12", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "2.2.10", - "vue-eslint-parser": "10.1.3", - "vue-tsc": "2.2.10" + "vue-component-type-helpers": "2.2.12", + "vue-eslint-parser": "10.2.0", + "vue-tsc": "2.2.12" } } diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index f6fd64153c..6453be0042 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -46,6 +46,7 @@ export default [ }, }, rules: { + '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index c4c4a25d74..5c33c38f44 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -111,6 +111,8 @@ export const ROLE_POLICIES = [ 'canImportUserLists', 'chatAvailability', 'uploadableFileTypes', + 'noteDraftLimit', + 'watermarkAvailable', ] as const; export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime']; diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index bd9bbbe7b6..7caa7fd283 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -21,20 +21,20 @@ "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { - "@types/node": "22.15.31", - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", - "esbuild": "0.25.5", - "eslint-plugin-vue": "10.2.0", + "@types/node": "22.16.4", + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "esbuild": "0.25.6", + "eslint-plugin-vue": "10.3.0", "nodemon": "3.1.10", "typescript": "5.8.3", - "vue-eslint-parser": "10.1.3" + "vue-eslint-parser": "10.2.0" }, "files": [ "js-built" ], "dependencies": { "misskey-js": "workspace:*", - "vue": "3.5.16" + "vue": "3.5.17" } } diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 8f835975a8..cbd36186e3 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -41,6 +41,7 @@ export default [ }, }, rules: { + '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], @@ -152,6 +153,9 @@ export default [ autofix: true, }], 'vue/attribute-hyphenation': ['error', 'never'], + 'vue/no-mutating-props': ['error', { + shallowOnly: true, + }], }, }, ]; diff --git a/packages/frontend/lib/vite-plugin-watch-locales.ts b/packages/frontend/lib/vite-plugin-watch-locales.ts new file mode 100644 index 0000000000..8e209d27bd --- /dev/null +++ b/packages/frontend/lib/vite-plugin-watch-locales.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import path from 'node:path' +import locales from '../../../locales/index.js'; + +const localesDir = path.resolve(__dirname, '../../../locales') + +/** + * 外部ファイルを監視し、必要に応じてwebSocketでメッセージを送るViteプラグイン + * @returns {import('vite').Plugin} + */ +export default function pluginWatchLocales() { + return { + name: 'watch-locales', + + configureServer(server) { + const localeYmlPaths = Object.keys(locales).map(locale => path.join(localesDir, `${locale}.yml`)); + + // watcherにパスを追加 + server.watcher.add(localeYmlPaths); + + server.watcher.on('change', (filePath) => { + if (localeYmlPaths.includes(filePath)) { + server.ws.send({ + type: 'custom', + event: 'locale-update', + data: filePath.match(/([^\/]+)\.yml$/)?.[1] || null, + }) + } + }); + }, + }; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 0dbd94362b..b8964549f4 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -23,19 +23,19 @@ "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.2", - "@rollup/pluginutils": "5.1.4", - "@sentry/vue": "9.27.0", + "@rollup/pluginutils": "5.2.0", + "@sentry/vue": "9.39.0", "@syuilo/aiscript": "0.19.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.4", - "@vue/compiler-sfc": "3.5.16", + "@vue/compiler-sfc": "3.5.17", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "analytics": "0.8.16", "astring": "1.9.0", "broadcast-channel": "7.1.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", - "chart.js": "4.4.9", + "chart.js": "4.5.0", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.1.1", "chartjs-plugin-gradient": "0.6.1", @@ -60,13 +60,13 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.42.0", + "rollup": "4.45.1", "sanitize-html": "2.17.0", "sass": "1.89.2", - "shiki": "3.6.0", + "shiki": "3.8.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.177.0", + "three": "0.178.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "tsc-alias": "1.8.16", @@ -74,12 +74,12 @@ "typescript": "5.8.3", "v-code-diff": "1.13.1", "vite": "6.3.5", - "vue": "3.5.16", + "vue": "3.5.17", "vuedraggable": "next", "wanakana": "5.3.1" }, "devDependencies": { - "@misskey-dev/summaly": "5.2.1", + "@misskey-dev/summaly": "5.2.2", "@storybook/addon-actions": "8.6.14", "@storybook/addon-essentials": "8.6.14", "@storybook/addon-interactions": "8.6.14", @@ -104,32 +104,32 @@ "@types/estree": "1.0.8", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.15.31", + "@types/node": "22.16.4", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.16.0", "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.34.0", - "@typescript-eslint/parser": "8.34.0", - "@vitest/coverage-v8": "3.2.3", - "@vue/compiler-core": "3.5.16", - "@vue/runtime-core": "3.5.16", + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "@vitest/coverage-v8": "3.2.4", + "@vue/compiler-core": "3.5.17", + "@vue/runtime-core": "3.5.17", "acorn": "8.15.0", "cross-env": "7.0.3", - "cypress": "14.4.1", - "eslint-plugin-import": "2.31.0", - "eslint-plugin-vue": "10.2.0", + "cypress": "14.5.2", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-vue": "10.3.0", "fast-glob": "3.3.3", "happy-dom": "17.6.3", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "minimatch": "10.0.1", - "msw": "2.10.2", + "minimatch": "10.0.3", + "msw": "2.10.4", "msw-storybook-addon": "2.0.5", "nodemon": "3.1.10", - "prettier": "3.5.3", + "prettier": "3.6.2", "react": "19.1.0", "react-dom": "19.1.0", "seedrandom": "3.0.5", @@ -137,10 +137,10 @@ "storybook": "8.6.14", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", - "vitest": "3.2.3", + "vitest": "3.2.4", "vitest-fetch-mock": "0.4.5", - "vue-component-type-helpers": "2.2.10", - "vue-eslint-parser": "10.1.3", - "vue-tsc": "2.2.10" + "vue-component-type-helpers": "2.2.12", + "vue-eslint-parser": "10.2.0", + "vue-tsc": "2.2.12" } } diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index c8098b6cf8..992bde9bd1 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -81,8 +81,10 @@ export async function common(createVue: () => Promise>) { //#region Detect language & fetch translations const localeVersion = miLocalStorage.getItem('localeVersion'); const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); - if (localeOutdated) { - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); + + async function fetchAndUpdateLocale({ useCache } = { useCache: true }) { + const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' }; + const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions); if (res.status === 200) { const newLocale = await res.text(); const parsedNewLocale = JSON.parse(newLocale); @@ -92,6 +94,23 @@ export async function common(createVue: () => Promise>) { updateI18n(parsedNewLocale); } } + + if (localeOutdated) { + fetchAndUpdateLocale(); + } + + if (import.meta.hot) { + import.meta.hot.on('locale-update', async (updatedLang: string) => { + console.info(`Locale updated: ${updatedLang}`); + if (updatedLang === lang) { + await new Promise(resolve => { + window.setTimeout(resolve, 500); + }); + await fetchAndUpdateLocale({ useCache: false }); + window.location.reload(); + } + }); + } //#endregion // タッチデバイスでCSSの:hoverを機能させる diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 891af7f696..a77ebd6ac5 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only