Merge remote-tracking branch 'origin/develop' into misskey-js

This commit is contained in:
Kagami Sascha Rosylight 2023-03-25 07:58:26 +01:00
commit 0b0a416566
122 changed files with 3817 additions and 589 deletions

View file

@ -12,6 +12,35 @@
--> -->
## 13.10.3
### General
- コンディショナルロールの条件に「投稿数が~以下」「投稿数が~以上」を追加
- リアクション非対応AP実装からのLikeアクティビティの解釈を👍から♥に
### Client
- クリップボタンをノートアクションに追加できるように
- センシティブワードの一覧にピン留めユーザーのIDが表示される問題を修正
### Server
- リモートユーザーのチャート生成を無効にするオプションを追加
- リモートサーバーのチャート生成を無効にするオプションを追加
- ドライブのチャートはローカルユーザーのみ生成するように
- 空のアンテナが作成できるのを修正
## 13.10.2
### Server
- 絵文字を編集すると保存できないことがある問題を修正
### Client
- ドライブファイルのメニューが正常に動作しない問題を修正
## 13.10.1
### Client
- Misskey PlayのPlayボタンを押した時にエラーが発生する問題を修正
## 13.10.0 ## 13.10.0
### General ### General
@ -22,15 +51,19 @@
- ロールの並び順を設定可能に - ロールの並び順を設定可能に
- カスタム絵文字にライセンス情報を付与できるように - カスタム絵文字にライセンス情報を付与できるように
- 指定した文字列を含む投稿の公開範囲をホームにできるように - 指定した文字列を含む投稿の公開範囲をホームにできるように
- 使われてないアンテナは自動停止されるように
### Client ### Client
- 設定から自分のロールを確認できるように - 設定から自分のロールを確認できるように
- 広告一覧ページを追加 - 広告一覧ページを追加
- ドライブクリーナーを追加
- DM作成時にメンションも含むように - DM作成時にメンションも含むように
- フォロー申請のボタンのデザインを改善 - フォロー申請のボタンのデザインを改善
- 付箋ウィジェットの高さを設定可能に - 付箋ウィジェットの高さを設定可能に
- APオブジェクトを入力してフェッチする機能とユーザーやートの検索機能を分離 - APオブジェクトを入力してフェッチする機能とユーザーやートの検索機能を分離
- ナビゲーションバーの項目に「プロフィール」を追加できるように - ナビゲーションバーの項目に「プロフィール」を追加できるように
- ナビゲーションバーのカスタマイズをドラッグ&ドロップで行えるように
- ジョブキューの再試行をワンクリックでできるように
- AiScriptを0.13.1に更新 - AiScriptを0.13.1に更新
- oEmbedをサポートしているウェブサイトのプレビューができるように - oEmbedをサポートしているウェブサイトのプレビューができるように
- YouTubeをoEmbedでロードし、プレビューで共有ボタンを押すとOSの共有画面がでるように - YouTubeをoEmbedでロードし、プレビューで共有ボタンを押すとOSの共有画面がでるように
@ -42,6 +75,7 @@
- Safariでプラグインが複数ある場合に正常に読み込まれない問題を修正 - Safariでプラグインが複数ある場合に正常に読み込まれない問題を修正
- Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正 - Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正
- 非ログイン時の「Misskeyについて」の表示を修正 - 非ログイン時の「Misskeyについて」の表示を修正
- PC版にて「設定」「コントロールパネル」のリンクを2度以上続けてクリックした際に空白のページが表示される問題を修正
### Server ### Server
- OpenAPIエンドポイントを復旧 - OpenAPIエンドポイントを復旧
@ -59,6 +93,7 @@
- リテンション分析が上手く機能しないことがあるのを修正 - リテンション分析が上手く機能しないことがあるのを修正
- 空のアンテナが作成できないように修正 - 空のアンテナが作成できないように修正
- 特定の条件で通報が見れない問題を修正 - 特定の条件で通報が見れない問題を修正
- 絵文字の名前に任意の文字が使用できる問題を修正
## 13.9.2 (2023/03/06) ## 13.9.2 (2023/03/06)

View file

@ -545,7 +545,6 @@ tokenRequested: "منح حق الوصول إلى الحساب"
pluginTokenRequestedDescription: "ستتمكن الإضافة من استخدام هذه الأذونات." pluginTokenRequestedDescription: "ستتمكن الإضافة من استخدام هذه الأذونات."
notificationType: "أنواع الإشعارات" notificationType: "أنواع الإشعارات"
edit: "التعديل" edit: "التعديل"
useStarForReactionFallback: "استخدم ★ كبديل إذا كان التفاعل مجهولًا"
emailServer: "خادم البريد الإلكتروني" emailServer: "خادم البريد الإلكتروني"
emailConfigInfo: "يستخدم لتأكيد عنوان بريدك الإلكتروني ولإعادة تعيين كلمة المرور إن نسيتها." emailConfigInfo: "يستخدم لتأكيد عنوان بريدك الإلكتروني ولإعادة تعيين كلمة المرور إن نسيتها."
email: "البريد الإلكتروني " email: "البريد الإلكتروني "
@ -1275,3 +1274,7 @@ _deck:
channel: "القنوات" channel: "القنوات"
mentions: "الإشارات" mentions: "الإشارات"
direct: "مباشرة" direct: "مباشرة"
_webhookSettings:
name: "الإسم"
active: "مفعّل"

View file

@ -562,7 +562,6 @@ tokenRequested: "অ্যাকাউন্টে অ্যাক্সেস
pluginTokenRequestedDescription: "এই প্লাগইনটি এখানে দেওয়া অনুমুতিসমূহ ব্যাবহার করবে" pluginTokenRequestedDescription: "এই প্লাগইনটি এখানে দেওয়া অনুমুতিসমূহ ব্যাবহার করবে"
notificationType: "বিজ্ঞপ্তির ধরন" notificationType: "বিজ্ঞপ্তির ধরন"
edit: "সম্পাদনা" edit: "সম্পাদনা"
useStarForReactionFallback: "রিঅ্যাকশনের ইমোজি না জানলে ★ ব্যবহার করুন"
emailServer: "ইমেইল সার্ভার" emailServer: "ইমেইল সার্ভার"
enableEmail: "ইমেইল বিতরণ চালু করুন" enableEmail: "ইমেইল বিতরণ চালু করুন"
emailConfigInfo: "আপনার ইমেল ঠিকানা নিশ্চিত করতে এবং আপনার পাসওয়ার্ড পুনরায় সেট করতে ব্যবহৃত হয়" emailConfigInfo: "আপনার ইমেল ঠিকানা নিশ্চিত করতে এবং আপনার পাসওয়ার্ড পুনরায় সেট করতে ব্যবহৃত হয়"
@ -1354,3 +1353,7 @@ _deck:
channel: "চ্যানেলগুলি" channel: "চ্যানেলগুলি"
mentions: "উল্লেখসমূহ" mentions: "উল্লেখসমূহ"
direct: "ডাইরেক্ট নোটগুলি" direct: "ডাইরেক্ট নোটগুলি"
_webhookSettings:
name: "নাম"
active: "চালু"

View file

@ -460,3 +460,4 @@ _deck:
list: "Llistes" list: "Llistes"
mentions: "Mencions" mentions: "Mencions"
direct: "Publicacions directes" direct: "Publicacions directes"

View file

@ -776,3 +776,7 @@ _deck:
list: "Seznamy" list: "Seznamy"
channel: "Kanály" channel: "Kanály"
mentions: "Zmínění" mentions: "Zmínění"
_webhookSettings:
name: "Jméno"
active: "Zapnuto"

View file

@ -1,2 +1,3 @@
--- ---
_lang_: "Dansk" _lang_: "Dansk"

View file

@ -594,7 +594,6 @@ tokenRequested: "Zugriff zum Benutzerkonto gewähren"
pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen verwenden können." pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen verwenden können."
notificationType: "Art der Benachrichtigung" notificationType: "Art der Benachrichtigung"
edit: "Bearbeiten" edit: "Bearbeiten"
useStarForReactionFallback: "Verwende ★ falls das Reaktions-Emoji unbekannt ist"
emailServer: "Email-Server" emailServer: "Email-Server"
enableEmail: "Email-Versand aktivieren" enableEmail: "Email-Versand aktivieren"
emailConfigInfo: "Zur Email-Bestätigung bei Registrierung oder zum Zurücksetzen des Passworts verwendet" emailConfigInfo: "Zur Email-Bestätigung bei Registrierung oder zum Zurücksetzen des Passworts verwendet"
@ -977,6 +976,10 @@ notesSearchNotAvailable: "Die Notizsuche ist nicht verfügbar."
license: "Lizenz" license: "Lizenz"
unfavoriteConfirm: "Wirklich aus Favoriten entfernen?" unfavoriteConfirm: "Wirklich aus Favoriten entfernen?"
myClips: "Meine Clips" myClips: "Meine Clips"
drivecleaner: "Drive-Reiniger"
retryAllQueuesNow: "Sofort Warteschlangen erneut ausführen"
retryAllQueuesConfirmTitle: "Wirklich erneut versuchen?"
retryAllQueuesConfirmText: "Dies wird zu einer temporären Erhöhung der Serverlast führen."
_achievements: _achievements:
earnedAt: "Freigeschaltet am" earnedAt: "Freigeschaltet am"
_types: _types:
@ -1273,6 +1276,8 @@ _role:
followersMoreThanOrEq: "Hat X oder mehr Follower" followersMoreThanOrEq: "Hat X oder mehr Follower"
followingLessThanOrEq: "Folgt X oder weniger Benutzern" followingLessThanOrEq: "Folgt X oder weniger Benutzern"
followingMoreThanOrEq: "Folgt X oder mehr Benutzern" followingMoreThanOrEq: "Folgt X oder mehr Benutzern"
notesLessThanOrEq: "Beitragszahl ist kleiner-gleich"
notesMoreThanOrEq: "Beitragszahl ist größer-gleich"
and: "UND-Bedingung" and: "UND-Bedingung"
or: "ODER-Bedingung" or: "ODER-Bedingung"
not: "NICHT-Bedingung" not: "NICHT-Bedingung"
@ -1868,3 +1873,10 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "Chronik deaktiviert" title: "Chronik deaktiviert"
description: "Mit deinen jetzigen Rollen ist diese Chronik nicht verfügbar." description: "Mit deinen jetzigen Rollen ist diese Chronik nicht verfügbar."
_drivecleaner:
orderBySizeDesc: "Absteigende Dateigrößen"
orderByCreatedAtAsc: "Aufsteigendes Erstelldatum"
_webhookSettings:
name: "Name"
active: "Aktiviert"

View file

@ -392,3 +392,6 @@ _deck:
antenna: "Αντένες" antenna: "Αντένες"
list: "Λίστα" list: "Λίστα"
mentions: "Επισημάνσεις" mentions: "Επισημάνσεις"
_webhookSettings:
name: "Όνομα"

View file

@ -530,7 +530,7 @@ nothing: "There's nothing to see here"
installedDate: "Authorized at" installedDate: "Authorized at"
lastUsedDate: "Last used at" lastUsedDate: "Last used at"
state: "State" state: "State"
sort: "Sort" sort: "Sorting order"
ascendingOrder: "Ascending" ascendingOrder: "Ascending"
descendingOrder: "Descending" descendingOrder: "Descending"
scratchpad: "Scratchpad" scratchpad: "Scratchpad"
@ -594,7 +594,6 @@ tokenRequested: "Grant access to account"
pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here."
notificationType: "Notification type" notificationType: "Notification type"
edit: "Edit" edit: "Edit"
useStarForReactionFallback: "Use ★ as fallback if the reaction emoji is unknown"
emailServer: "Email server" emailServer: "Email server"
enableEmail: "Enable email distribution" enableEmail: "Enable email distribution"
emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password"
@ -977,6 +976,13 @@ notesSearchNotAvailable: "Note search is unavailable."
license: "License" license: "License"
unfavoriteConfirm: "Really remove from favorites?" unfavoriteConfirm: "Really remove from favorites?"
myClips: "My clips" myClips: "My clips"
drivecleaner: "Drive Cleaner"
retryAllQueuesNow: "Retry running all queues"
retryAllQueuesConfirmTitle: "Really retry all?"
retryAllQueuesConfirmText: "This will temporarily increase the server load."
enableChartsForRemoteUser: "Generate remote user data charts"
enableChartsForFederatedInstances: "Generate remote instance data charts"
showClipButtonInNoteFooter: "Add \"Clip\" to note action menu"
_achievements: _achievements:
earnedAt: "Unlocked at" earnedAt: "Unlocked at"
_types: _types:
@ -1273,6 +1279,8 @@ _role:
followersMoreThanOrEq: "Has X or more followers" followersMoreThanOrEq: "Has X or more followers"
followingLessThanOrEq: "Follows X or fewer accounts" followingLessThanOrEq: "Follows X or fewer accounts"
followingMoreThanOrEq: "Follows X or more accounts" followingMoreThanOrEq: "Follows X or more accounts"
notesLessThanOrEq: "Post count is less than/equal to"
notesMoreThanOrEq: "Post count is greater than/equal to"
and: "AND-Condition" and: "AND-Condition"
or: "OR-Condition" or: "OR-Condition"
not: "NOT-Condition" not: "NOT-Condition"
@ -1868,3 +1876,21 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "Timeline disabled" title: "Timeline disabled"
description: "You cannot use this timeline under your current roles." description: "You cannot use this timeline under your current roles."
_drivecleaner:
orderBySizeDesc: "Descending Filesizes"
orderByCreatedAtAsc: "Ascending Dates"
_webhookSettings:
createWebhook: "Create Webhook"
name: "Name"
secret: "Secret"
events: "Webhook Events"
active: "Enabled"
_events:
follow: "When following a user"
followed: "When being followed"
note: "When posting a note"
reply: "When receiving a reply"
renote: "When renoted"
reaction: "When receiving a reaction"
mention: "When being mentioned"

View file

@ -594,7 +594,6 @@ tokenRequested: "Permiso de acceso a la cuenta"
pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí" pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí"
notificationType: "Tipo de notificación" notificationType: "Tipo de notificación"
edit: "Editar" edit: "Editar"
useStarForReactionFallback: "En caso de que los emojis de reacciones no sean claros, usar en su lugar una estrella"
emailServer: "Servidor de correo" emailServer: "Servidor de correo"
enableEmail: "Activar el envío de correos electrónicos" enableEmail: "Activar el envío de correos electrónicos"
emailConfigInfo: "Usar en caso de validación de correo electrónico y pedido de contraseña" emailConfigInfo: "Usar en caso de validación de correo electrónico y pedido de contraseña"
@ -973,6 +972,14 @@ rolesAssignedToMe: "Roles asignados a mí"
resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?" resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?"
sensitiveWords: "Palabras sensibles" sensitiveWords: "Palabras sensibles"
sensitiveWordsDescription: "La visibilidad de todas las notas que contienen cualquiera de las palabras configuradas serán puestas en \"Inicio\" automáticamente. Puedes enumerás varias separándolas con saltos de línea" sensitiveWordsDescription: "La visibilidad de todas las notas que contienen cualquiera de las palabras configuradas serán puestas en \"Inicio\" automáticamente. Puedes enumerás varias separándolas con saltos de línea"
notesSearchNotAvailable: "No se puede buscar una nota"
license: "Licencia"
unfavoriteConfirm: "¿Desea quitar de favoritos?"
myClips: "Mis clips"
drivecleaner: "Limpiador del Drive"
retryAllQueuesNow: "Reintentar inmediatamente todas las colas"
retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?"
retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente "
_achievements: _achievements:
earnedAt: "Desbloqueado el" earnedAt: "Desbloqueado el"
_types: _types:
@ -1864,3 +1871,10 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "Línea de tiempo deshabilitada" title: "Línea de tiempo deshabilitada"
description: "No puedes usar esta línea de tiempo con tus roles actuales." description: "No puedes usar esta línea de tiempo con tus roles actuales."
_drivecleaner:
orderBySizeDesc: "Más grandes"
orderByCreatedAtAsc: "Más antiguos"
_webhookSettings:
name: "Nombre"
active: "Activado"

View file

@ -575,7 +575,6 @@ tokenRequested: "Autoriser l'accès au compte"
pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici." pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici."
notificationType: "Type de notifications" notificationType: "Type de notifications"
edit: "Editer" edit: "Editer"
useStarForReactionFallback: "Utiliser ★ comme alternative si lémoji de réaction est inconnu"
emailServer: "Serveur mail" emailServer: "Serveur mail"
enableEmail: "Activer la distribution de courriel" enableEmail: "Activer la distribution de courriel"
emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation de votre mot de passe en cas doubli." emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation de votre mot de passe en cas doubli."
@ -1468,3 +1467,7 @@ _deck:
channel: "Canaux" channel: "Canaux"
mentions: "Mentions" mentions: "Mentions"
direct: "Direct" direct: "Direct"
_webhookSettings:
name: "Nom"
active: "Activé"

View file

@ -1 +1,2 @@
--- ---

View file

@ -1 +1,2 @@
--- ---

View file

@ -579,7 +579,6 @@ tokenRequested: "Berikan ijin akses ke akun"
pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini." pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini."
notificationType: "Jenis pemberitahuan" notificationType: "Jenis pemberitahuan"
edit: "Sunting" edit: "Sunting"
useStarForReactionFallback: "Gunakan ★ sebagai fallback jika reaksi emoji tidak diketahui"
emailServer: "Peladen surel" emailServer: "Peladen surel"
enableEmail: "Nyalakan distribusi surel" enableEmail: "Nyalakan distribusi surel"
emailConfigInfo: "Digunakan untuk mengonfirmasi surel kamu disaat mendaftar dan lupa kata sandi" emailConfigInfo: "Digunakan untuk mengonfirmasi surel kamu disaat mendaftar dan lupa kata sandi"
@ -1804,3 +1803,7 @@ _deck:
channel: "Kanal" channel: "Kanal"
mentions: "Sebutan" mentions: "Sebutan"
direct: "Langsung" direct: "Langsung"
_webhookSettings:
name: "Nama"
active: "Aktif"

View file

@ -565,8 +565,8 @@ enableInfiniteScroll: "Abilita scorrimento infinito"
visibility: "Visibilità" visibility: "Visibilità"
poll: "Sondaggio" poll: "Sondaggio"
useCw: "Nascondere media" useCw: "Nascondere media"
enablePlayer: "Apri in lettore video" enablePlayer: "Visualizza"
disablePlayer: "Chiudi il lettore" disablePlayer: "Chiudi"
expandTweet: "Espandi tweet" expandTweet: "Espandi tweet"
themeEditor: "Editor di temi" themeEditor: "Editor di temi"
description: "Descrizione" description: "Descrizione"
@ -594,7 +594,6 @@ tokenRequested: "Autorizza accesso al profilo"
pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui."
notificationType: "Tipo di notifiche" notificationType: "Tipo di notifiche"
edit: "Modifica" edit: "Modifica"
useStarForReactionFallback: "Se è sconosciuto l'emoji di reazione, usare la ★ come alternativa."
emailServer: "Server email" emailServer: "Server email"
enableEmail: "Abilita consegna email" enableEmail: "Abilita consegna email"
emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per reimpostare la tua password" emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per reimpostare la tua password"
@ -977,6 +976,10 @@ notesSearchNotAvailable: "Non è possibile cercare tra le Note."
license: "Licenza" license: "Licenza"
unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?"
myClips: "Le mie Clip" myClips: "Le mie Clip"
drivecleaner: "Drive cleaner"
retryAllQueuesNow: "Ritenta di consumare tutte le code"
retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?"
retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente."
_achievements: _achievements:
earnedAt: "Data di conseguimento" earnedAt: "Data di conseguimento"
_types: _types:
@ -1868,3 +1871,10 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "Timeline disabilitata" title: "Timeline disabilitata"
description: "Il tuo ruolo non ha i permessi per accedere a questa timeline" description: "Il tuo ruolo non ha i permessi per accedere a questa timeline"
_drivecleaner:
orderBySizeDesc: "Dal più grande al più piccolo"
orderByCreatedAtAsc: "Dal più vecchio al più recente"
_webhookSettings:
name: "Nome"
active: "Attivo"

View file

@ -460,7 +460,7 @@ aboutX: "{x}について"
emojiStyle: "絵文字のスタイル" emojiStyle: "絵文字のスタイル"
native: "ネイティブ" native: "ネイティブ"
disableDrawer: "メニューをドロワーで表示しない" disableDrawer: "メニューをドロワーで表示しない"
showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示する" showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する"
noHistory: "履歴はありません" noHistory: "履歴はありません"
signinHistory: "ログイン履歴" signinHistory: "ログイン履歴"
enableAdvancedMfm: "高度なMFMを有効にする" enableAdvancedMfm: "高度なMFMを有効にする"
@ -594,7 +594,6 @@ tokenRequested: "アカウントへのアクセス許可"
pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。"
notificationType: "通知の種類" notificationType: "通知の種類"
edit: "編集" edit: "編集"
useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う"
emailServer: "メールサーバー" emailServer: "メールサーバー"
enableEmail: "メール配信機能を有効化する" enableEmail: "メール配信機能を有効化する"
emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います"
@ -977,6 +976,13 @@ notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス" license: "ライセンス"
unfavoriteConfirm: "お気に入り解除しますか?" unfavoriteConfirm: "お気に入り解除しますか?"
myClips: "自分のクリップ" myClips: "自分のクリップ"
drivecleaner: "ドライブクリーナー"
retryAllQueuesNow: "すべてのキューを今すぐ再試行"
retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
_achievements: _achievements:
earnedAt: "獲得日時" earnedAt: "獲得日時"
@ -1275,6 +1281,8 @@ _role:
followersMoreThanOrEq: "フォロワー数が~以上" followersMoreThanOrEq: "フォロワー数が~以上"
followingLessThanOrEq: "フォロー数が~以下" followingLessThanOrEq: "フォロー数が~以下"
followingMoreThanOrEq: "フォロー数が~以上" followingMoreThanOrEq: "フォロー数が~以上"
notesLessThanOrEq: "投稿数が~以下"
notesMoreThanOrEq: "投稿数が~以上"
and: "~かつ~" and: "~かつ~"
or: "~または~" or: "~または~"
not: "~ではない" not: "~ではない"
@ -1922,3 +1930,23 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "無効化されたタイムライン" title: "無効化されたタイムライン"
description: "現在のロールでは、このタイムラインを使用することはできません。" description: "現在のロールでは、このタイムラインを使用することはできません。"
_drivecleaner:
orderBySizeDesc: "サイズが大きい順"
orderByCreatedAtAsc: "追加日が古い順"
_webhookSettings:
createWebhook: "Webhookを作成"
name: "名前"
secret: "シークレット"
events: "Webhookを実行するタイミング"
active: "有効"
_events:
follow: "フォローしたとき"
followed: "フォローされたとき"
note: "ノートを投稿したとき"
reply: "返信されたとき"
renote: "Renoteされたとき"
reaction: "リアクションがあったとき"
mention: "メンションされたとき"

View file

@ -594,7 +594,6 @@ tokenRequested: "アカウントへのアクセス許してやったらどうや
pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。"
notificationType: "通知の種類" notificationType: "通知の種類"
edit: "編集" edit: "編集"
useStarForReactionFallback: "リアクションがようわからん場合、★を使う"
emailServer: "メールサーバー" emailServer: "メールサーバー"
enableEmail: "メール配信を受け取る" enableEmail: "メール配信を受け取る"
emailConfigInfo: "メールアドレスの確認とかパスワードリセットの時に使うで" emailConfigInfo: "メールアドレスの確認とかパスワードリセットの時に使うで"
@ -977,6 +976,13 @@ notesSearchNotAvailable: "ノート検索は使われへんで。"
license: "ライセンス" license: "ライセンス"
unfavoriteConfirm: "ほんまに気に入らんの?" unfavoriteConfirm: "ほんまに気に入らんの?"
myClips: "自分のクリップ" myClips: "自分のクリップ"
drivecleaner: "ドライブキレイキレイ"
retryAllQueuesNow: "キューを全部もっかいやり直す"
retryAllQueuesConfirmTitle: "もっかいやってみるか?"
retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへんで。"
enableChartsForRemoteUser: "リモートユーザーのチャートを作る"
enableChartsForFederatedInstances: "リモートサーバーのチャートを作る"
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
_achievements: _achievements:
earnedAt: "貰った日ぃ" earnedAt: "貰った日ぃ"
_types: _types:
@ -1273,6 +1279,8 @@ _role:
followersMoreThanOrEq: "フォロワー数が~以上" followersMoreThanOrEq: "フォロワー数が~以上"
followingLessThanOrEq: "フォロー数が~以下" followingLessThanOrEq: "フォロー数が~以下"
followingMoreThanOrEq: "フォロー数が~以上" followingMoreThanOrEq: "フォロー数が~以上"
notesLessThanOrEq: "投稿数が~以下しかない"
notesMoreThanOrEq: "投稿を~以上しとる"
and: "~かつ~" and: "~かつ~"
or: "~または~" or: "~または~"
not: "~ではない" not: "~ではない"
@ -1868,3 +1876,21 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "使われへんタイムライン" title: "使われへんタイムライン"
description: "あんたの今のロールやったら、このタイムラインは使われへんで。" description: "あんたの今のロールやったら、このタイムラインは使われへんで。"
_drivecleaner:
orderBySizeDesc: "サイズのでかい順"
orderByCreatedAtAsc: "追加日の古い順"
_webhookSettings:
createWebhook: "Webhookをつくる"
name: "名前"
secret: "シークレット"
events: "Webhookを投げるタイミング"
active: "有効"
_events:
follow: "フォローしたとき~!"
followed: "フォローもらったとき~!"
note: "ノートを投稿したとき~!"
reply: "返信があるとき~!"
renote: "Renoteされるとき"
reaction: "リアクションがあるとき~!"
mention: "メンションがあるとき~!"

View file

@ -1 +1,2 @@
--- ---

View file

@ -103,3 +103,4 @@ _deck:
_columns: _columns:
notifications: "Ilɣuyen" notifications: "Ilɣuyen"
list: "Tibdarin" list: "Tibdarin"

View file

@ -83,3 +83,4 @@ _deck:
notifications: "ಅಧಿಸೂಚನೆಗಳು" notifications: "ಅಧಿಸೂಚನೆಗಳು"
tl: "ಸಮಯಸಾಲು" tl: "ಸಮಯಸಾಲು"
mentions: "ಹೆಸರಿಸಿದ" mentions: "ಹೆಸರಿಸಿದ"

View file

@ -592,7 +592,6 @@ tokenRequested: "계정 접근 허용"
pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다."
notificationType: "알림 유형" notificationType: "알림 유형"
edit: "편집" edit: "편집"
useStarForReactionFallback: "알 수 없는 리액션 이모지 대신 ★ 사용"
emailServer: "메일 서버" emailServer: "메일 서버"
enableEmail: "이메일 송신 기능 활성화" enableEmail: "이메일 송신 기능 활성화"
emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다."
@ -1849,3 +1848,7 @@ _deck:
_dialog: _dialog:
charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}" charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}"
charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}"
_webhookSettings:
name: "이름"
active: "활성화"

View file

@ -368,3 +368,4 @@ _deck:
list: "ລາຍການ" list: "ລາຍການ"
channel: "ຊ່ອງ" channel: "ຊ່ອງ"
mentions: "ກ່າວເຖິງ" mentions: "ກ່າວເຖິງ"

View file

@ -483,3 +483,6 @@ _deck:
antenna: "Antennes" antenna: "Antennes"
list: "Lijsten" list: "Lijsten"
mentions: "Vermeldingen" mentions: "Vermeldingen"
_webhookSettings:
name: "Naam"

View file

@ -1,2 +1,3 @@
--- ---
_lang_: "Norsk Bokmål" _lang_: "Norsk Bokmål"

View file

@ -564,7 +564,6 @@ tokenRequested: "Przydziel dostęp do konta"
pluginTokenRequestedDescription: "Ta wtyczka będzie mogła korzystać z ustawionych tu uprawnień." pluginTokenRequestedDescription: "Ta wtyczka będzie mogła korzystać z ustawionych tu uprawnień."
notificationType: "Rodzaj powiadomień" notificationType: "Rodzaj powiadomień"
edit: "Edytuj" edit: "Edytuj"
useStarForReactionFallback: "Użyj ★ jako zapasowego emoji, gdy emoji reakcji jest nieznane"
emailServer: "Serwer poczty e-mail" emailServer: "Serwer poczty e-mail"
enableEmail: "Włącz dostarczanie wiadomości e-mail" enableEmail: "Włącz dostarczanie wiadomości e-mail"
emailConfigInfo: "Wykorzystywany do potwierdzenia adresu e-mail w trakcie rejestracji, lub gdy zapomnisz hasła" emailConfigInfo: "Wykorzystywany do potwierdzenia adresu e-mail w trakcie rejestracji, lub gdy zapomnisz hasła"
@ -1358,3 +1357,7 @@ _deck:
channel: "Kanały" channel: "Kanały"
mentions: "Wspomnienia" mentions: "Wspomnienia"
direct: "Bezpośredni" direct: "Bezpośredni"
_webhookSettings:
name: "Nazwa"
active: "Właczono"

View file

@ -555,3 +555,6 @@ _deck:
list: "Listas" list: "Listas"
mentions: "Menções" mentions: "Menções"
direct: "Notas diretas" direct: "Notas diretas"
_webhookSettings:
name: "Nome"

View file

@ -561,7 +561,6 @@ tokenRequested: "Acordă acces la cont"
pluginTokenRequestedDescription: "Acest plugin va putea să folosească permisiunile setate aici." pluginTokenRequestedDescription: "Acest plugin va putea să folosească permisiunile setate aici."
notificationType: "Tipul notificării" notificationType: "Tipul notificării"
edit: "Editează" edit: "Editează"
useStarForReactionFallback: "Folosește ★ ca fallback dacă emoji-ul este necunoscut"
emailServer: "Server email" emailServer: "Server email"
enableEmail: "Activează distribuția de emailuri" enableEmail: "Activează distribuția de emailuri"
emailConfigInfo: "Folosit pentru a confirma emailul tău în timpul logări dacă îți uiți parola" emailConfigInfo: "Folosit pentru a confirma emailul tău în timpul logări dacă îți uiți parola"
@ -702,3 +701,6 @@ _deck:
list: "Liste" list: "Liste"
channel: "Canale" channel: "Canale"
mentions: "Mențiuni" mentions: "Mențiuni"
_webhookSettings:
name: "Nume"

View file

@ -585,7 +585,6 @@ tokenRequested: "Открыть доступ к учётной записи"
pluginTokenRequestedDescription: "Это расширение сможет пользоваться разрешениями, установленными здесь." pluginTokenRequestedDescription: "Это расширение сможет пользоваться разрешениями, установленными здесь."
notificationType: "Тип уведомления" notificationType: "Тип уведомления"
edit: "Изменить" edit: "Изменить"
useStarForReactionFallback: "Ставить ★ в качестве реакции вместо неизвестного эмодзи"
emailServer: "Сервер электронной почты" emailServer: "Сервер электронной почты"
enableEmail: "Включить обмен электронной почтой" enableEmail: "Включить обмен электронной почтой"
emailConfigInfo: "Используется для подтверждения адреса электронной почты и сброса пароля." emailConfigInfo: "Используется для подтверждения адреса электронной почты и сброса пароля."
@ -1837,3 +1836,7 @@ _deck:
_dialog: _dialog:
charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}" charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}"
charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}" charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}"
_webhookSettings:
name: "Название"
active: "Вкл."

View file

@ -1 +1,2 @@
--- ---

View file

@ -586,7 +586,6 @@ tokenRequested: "Povoliť prístup k účtu"
pluginTokenRequestedDescription: "Tento plugin bude môcť používať oprávnenia nastavené tu." pluginTokenRequestedDescription: "Tento plugin bude môcť používať oprávnenia nastavené tu."
notificationType: "Typ oznámenia" notificationType: "Typ oznámenia"
edit: "Upraviť" edit: "Upraviť"
useStarForReactionFallback: "Použiť ★ keď emoji reakcie nie je známe"
emailServer: "Email server" emailServer: "Email server"
enableEmail: "Zapnúť email" enableEmail: "Zapnúť email"
emailConfigInfo: "Používa sa na overenie emaily pri registrácii alebo pri zabudnutí hesla" emailConfigInfo: "Používa sa na overenie emaily pri registrácii alebo pri zabudnutí hesla"
@ -1475,3 +1474,7 @@ _deck:
channel: "Kanály" channel: "Kanály"
mentions: "Zmienky" mentions: "Zmienky"
direct: "Priame poznámky" direct: "Priame poznámky"
_webhookSettings:
name: "Názov"
active: "Zapnuté"

View file

@ -442,3 +442,6 @@ _deck:
antenna: "Antenner" antenna: "Antenner"
list: "Listor" list: "Listor"
mentions: "Omnämningar" mentions: "Omnämningar"
_webhookSettings:
active: "Aktiverad"

View file

@ -544,6 +544,8 @@ userSuspended: "ผู้ใช้รายนี้ถูกระงับก
userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น" userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น"
yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ"
yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่"
tokenRevoked: "โทเค็นไม่ถูกต้อง"
accountDeleted: "ลบบัญชีแล้ว"
menu: "เมนู" menu: "เมนู"
divider: "ตัวแบ่ง" divider: "ตัวแบ่ง"
addItem: "เพิ่มรายการ" addItem: "เพิ่มรายการ"
@ -587,7 +589,6 @@ tokenRequested: "ให้สิทธิ์การเข้าถึงบั
pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ" pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ"
notificationType: "ประเภทการแจ้งเตือน" notificationType: "ประเภทการแจ้งเตือน"
edit: "แก้ไข" edit: "แก้ไข"
useStarForReactionFallback: "ใช้ ★ เป็นทางเลือกแทนถ้าหากไม่ทราบอิโมจิ"
emailServer: "อีเมล์เซิร์ฟเวอร์" emailServer: "อีเมล์เซิร์ฟเวอร์"
enableEmail: "เปิดใช้งานการกระจายอีเมล" enableEmail: "เปิดใช้งานการกระจายอีเมล"
emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน" emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน"
@ -959,6 +960,18 @@ invitationRequiredToRegister: "อินสแตนซ์นี้เป็น
emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ"
postToTheChannel: "โพสต์ลงช่อง" postToTheChannel: "โพสต์ลงช่อง"
cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ"
likeOnly: "ที่ชอบเท่านั้น"
resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?"
sensitiveWords: "คำที่ละเอียดอ่อน"
sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ"
notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งานนะค่ะ"
license: "ใบอนุญาต"
unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?"
myClips: "คลิปของฉัน"
drivecleaner: "ทำความสะอาดไดรฟ์"
retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง"
retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?"
retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ"
_achievements: _achievements:
earnedAt: "ได้รับเมื่อ" earnedAt: "ได้รับเมื่อ"
_types: _types:
@ -1218,6 +1231,8 @@ _role:
iconUrl: "ไอคอน URL" iconUrl: "ไอคอน URL"
asBadge: "แสดงเป็นตรา" asBadge: "แสดงเป็นตรา"
descriptionOfAsBadge: "ไอคอนของบทบาทนี้จะปรากฏถัดจากชื่อผู้ใช้ของผู้ใช้งานด้วยบทบาทนี้ถ้าหากเปิดใช้งาน" descriptionOfAsBadge: "ไอคอนของบทบาทนี้จะปรากฏถัดจากชื่อผู้ใช้ของผู้ใช้งานด้วยบทบาทนี้ถ้าหากเปิดใช้งาน"
displayOrder: "ตำแหน่ง"
descriptionOfDisplayOrder: "ยิ่งตัวเลขสูง ตำแหน่ง UI ก็ยิ่งสูงขึ้นนะ"
canEditMembersByModerator: "อนุญาตให้ผู้ดูแลแก้ไขสมาชิก" canEditMembersByModerator: "อนุญาตให้ผู้ดูแลแก้ไขสมาชิก"
descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ ผู้ดูแลนอกเหนือจากผู้ดูแลระบบแล้ว จะสามารถกำหนดและยกเลิกการมอบหมายบทบาทนี้ให้กับผู้ใช้ได้ เมื่อปิด เฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถกำหนดผู้ใช้ได้นะ" descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ ผู้ดูแลนอกเหนือจากผู้ดูแลระบบแล้ว จะสามารถกำหนดและยกเลิกการมอบหมายบทบาทนี้ให้กับผู้ใช้ได้ เมื่อปิด เฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถกำหนดผู้ใช้ได้นะ"
priority: "ลำดับความสำคัญ" priority: "ลำดับความสำคัญ"
@ -1243,6 +1258,7 @@ _role:
rateLimitFactor: "ขีดจำกัดอัตรา" rateLimitFactor: "ขีดจำกัดอัตรา"
descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า" descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า"
canHideAds: "ซ่อนโฆษณา" canHideAds: "ซ่อนโฆษณา"
canSearchNotes: "การใช้การค้นหาโน้ต"
_condition: _condition:
isLocal: "ผู้ใช้ภายใน" isLocal: "ผู้ใช้ภายใน"
isRemote: "ผู้ใช้ระยะไกล" isRemote: "ผู้ใช้ระยะไกล"
@ -1844,3 +1860,13 @@ _deck:
_dialog: _dialog:
charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}"
charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}"
_disabledTimeline:
title: "ปิดใช้งานไทม์ไลน์"
description: "คุณไม่สามารถใช้ไทม์ไลน์นี้ภายใต้บทบาทปัจจุบันของคุณได้"
_drivecleaner:
orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย"
orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก"
_webhookSettings:
name: "ชื่อ"
active: "เปิดใช้งาน"

View file

@ -60,3 +60,4 @@ _deck:
_columns: _columns:
notifications: "Bildirim" notifications: "Bildirim"
tl: "Zaman çizelgesi" tl: "Zaman çizelgesi"

View file

@ -2,3 +2,4 @@
_lang_: "ياپونچە" _lang_: "ياپونچە"
search: "ئىزدەش" search: "ئىزدەش"
searchByGoogle: "ئىزدەش" searchByGoogle: "ئىزدەش"

View file

@ -576,7 +576,6 @@ tokenRequested: "Надати доступ до акаунту"
pluginTokenRequestedDescription: "Цей плагін зможе використовувати дозволи які тут вказані." pluginTokenRequestedDescription: "Цей плагін зможе використовувати дозволи які тут вказані."
notificationType: "Тип сповіщення" notificationType: "Тип сповіщення"
edit: "Редагувати" edit: "Редагувати"
useStarForReactionFallback: "Використовувати ★ як запасний варіант, якщо емодзі реакції невідомий"
emailServer: "Email сервер" emailServer: "Email сервер"
enableEmail: "Увімкнути функцію доставки пошти" enableEmail: "Увімкнути функцію доставки пошти"
emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю." emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю."
@ -1639,3 +1638,7 @@ _deck:
channel: "Канали" channel: "Канали"
mentions: "Згадки" mentions: "Згадки"
direct: "Особисте" direct: "Особисте"
_webhookSettings:
name: "Ім'я"
active: "Увімкнено"

View file

@ -585,7 +585,6 @@ tokenRequested: "Cấp quyền truy cập vào tài khoản"
pluginTokenRequestedDescription: "Plugin này sẽ có thể sử dụng các quyền được đặt ở đây." pluginTokenRequestedDescription: "Plugin này sẽ có thể sử dụng các quyền được đặt ở đây."
notificationType: "Loại thông báo" notificationType: "Loại thông báo"
edit: "Sửa" edit: "Sửa"
useStarForReactionFallback: "Dùng ★ nếu emoji biểu cảm không có"
emailServer: "Email máy chủ" emailServer: "Email máy chủ"
enableEmail: "Bật phân phối email" enableEmail: "Bật phân phối email"
emailConfigInfo: "Được dùng để xác minh email của bạn lúc đăng ký hoặc nếu bạn quên mật khẩu của mình" emailConfigInfo: "Được dùng để xác minh email của bạn lúc đăng ký hoặc nếu bạn quên mật khẩu của mình"
@ -1705,3 +1704,7 @@ _deck:
_dialog: _dialog:
charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}" charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}"
charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}" charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}"
_webhookSettings:
name: "Tên"
active: "Đã bật"

View file

@ -594,7 +594,6 @@ tokenRequested: "允许访问账户"
pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限" pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限"
notificationType: "通知类型" notificationType: "通知类型"
edit: "编辑" edit: "编辑"
useStarForReactionFallback: "如果回应的是未知表情符号,则使用★作为代替"
emailServer: "邮件服务器" emailServer: "邮件服务器"
enableEmail: "启用发送邮件功能" enableEmail: "启用发送邮件功能"
emailConfigInfo: "用于确认电子邮件和密码重置" emailConfigInfo: "用于确认电子邮件和密码重置"
@ -977,6 +976,10 @@ notesSearchNotAvailable: "帖子检索不可用"
license: "许可信息" license: "许可信息"
unfavoriteConfirm: "确定要取消收藏吗?" unfavoriteConfirm: "确定要取消收藏吗?"
myClips: "我的便签" myClips: "我的便签"
drivecleaner: "网盘整理"
retryAllQueuesNow: "立刻重试所有队列"
retryAllQueuesConfirmTitle: "要再尝试一次吗?"
retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
_achievements: _achievements:
earnedAt: "达成时间" earnedAt: "达成时间"
_types: _types:
@ -1868,3 +1871,10 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "时间线已禁用" title: "时间线已禁用"
description: "您不能在当前角色使用时间线。" description: "您不能在当前角色使用时间线。"
_drivecleaner:
orderBySizeDesc: "按大小降序排列"
orderByCreatedAtAsc: "按添加日期降序排列"
_webhookSettings:
name: "名称"
active: "已启用"

View file

@ -531,8 +531,8 @@ installedDate: "安裝時間"
lastUsedDate: "最後上線日期" lastUsedDate: "最後上線日期"
state: "狀態" state: "狀態"
sort: "排序" sort: "排序"
ascendingOrder: "遞增" ascendingOrder: "昇冪"
descendingOrder: "遞減" descendingOrder: "降冪"
scratchpad: "暫存記憶體" scratchpad: "暫存記憶體"
scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Misskey互動的结果。" scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Misskey互動的结果。"
output: "輸出" output: "輸出"
@ -594,7 +594,6 @@ tokenRequested: "允許存取帳戶"
pluginTokenRequestedDescription: "此外掛將擁有在此設定的權限。" pluginTokenRequestedDescription: "此外掛將擁有在此設定的權限。"
notificationType: "通知形式" notificationType: "通知形式"
edit: "編輯" edit: "編輯"
useStarForReactionFallback: "以★代替未知的表情符號"
emailServer: "電郵伺服器" emailServer: "電郵伺服器"
enableEmail: "啟用發送電郵功能" enableEmail: "啟用發送電郵功能"
emailConfigInfo: "用於確認電郵地址及密碼重置" emailConfigInfo: "用於確認電郵地址及密碼重置"
@ -678,8 +677,8 @@ sentReactionsCount: "反應發送次數"
receivedReactionsCount: "收到反應次數" receivedReactionsCount: "收到反應次數"
pollVotesCount: "已統計的投票數" pollVotesCount: "已統計的投票數"
pollVotedCount: "已投票數" pollVotedCount: "已投票數"
yes: "確定" yes: ""
no: "取消" no: ""
driveFilesCount: "雲端硬碟檔案數量" driveFilesCount: "雲端硬碟檔案數量"
driveUsage: "雲端硬碟使用量" driveUsage: "雲端硬碟使用量"
noCrawle: "拒絕搜尋引擎索引" noCrawle: "拒絕搜尋引擎索引"
@ -973,6 +972,14 @@ rolesAssignedToMe: "指派給自己的角色"
resetPasswordConfirm: "重設密碼?" resetPasswordConfirm: "重設密碼?"
sensitiveWords: "敏感詞" sensitiveWords: "敏感詞"
sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。" sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。"
notesSearchNotAvailable: "無法使用搜尋貼文功能。"
license: "授權"
unfavoriteConfirm: "要取消收錄我的最愛嗎?"
myClips: "我的摘錄"
drivecleaner: "雲端硬碟清掃器"
retryAllQueuesNow: "立刻重試所有佇列"
retryAllQueuesConfirmTitle: "要現在重試嗎?"
retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。"
_achievements: _achievements:
earnedAt: "獲得日期" earnedAt: "獲得日期"
_types: _types:
@ -1498,7 +1505,7 @@ _time:
_tutorial: _tutorial:
title: "Misskey使用方法" title: "Misskey使用方法"
step1_1: "歡迎!" step1_1: "歡迎!"
step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」" step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」"
step1_3: "由於你沒有發佈任何貼文,也沒有追隨任何人,所以你的時間軸目前是空的。" step1_3: "由於你沒有發佈任何貼文,也沒有追隨任何人,所以你的時間軸目前是空的。"
step2_1: "在發文或追隨其他人之前先讓我們設定一下個人資料吧。" step2_1: "在發文或追隨其他人之前先讓我們設定一下個人資料吧。"
step2_2: "提供一些關於自己的資訊來讓其他人更有追隨你的意願。" step2_2: "提供一些關於自己的資訊來讓其他人更有追隨你的意願。"
@ -1864,3 +1871,10 @@ _dialog:
_disabledTimeline: _disabledTimeline:
title: "停用的時間軸" title: "停用的時間軸"
description: "目前的角色無法使用這個時間軸。" description: "目前的角色無法使用這個時間軸。"
_drivecleaner:
orderBySizeDesc: "檔案由大到小"
orderByCreatedAtAsc: "依照加入的日期順序"
_webhookSettings:
name: "名稱"
active: "已啟用"

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.10.0", "version": "13.10.3",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -0,0 +1,17 @@
export class antennaActive1679309757174 {
name = 'antennaActive1679309757174'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "antenna" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'now'`);
await queryRunner.query(`ALTER TABLE "antenna" ADD "isActive" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`CREATE INDEX "IDX_084c2abb8948ef59a37dce6ac1" ON "antenna" ("lastUsedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_36ef5192a1ce55ed0e40aa4db5" ON "antenna" ("isActive") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_36ef5192a1ce55ed0e40aa4db5"`);
await queryRunner.query(`DROP INDEX "public"."IDX_084c2abb8948ef59a37dce6ac1"`);
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isActive"`);
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "lastUsedAt"`);
}
}

View file

@ -0,0 +1,11 @@
export class enableChartsForRemoteUser1679639483253 {
name = 'enableChartsForRemoteUser1679639483253'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForRemoteUser" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForRemoteUser"`);
}
}

View file

@ -0,0 +1,11 @@
export class cleanup1679651580149 {
name = 'cleanup1679651580149'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`);
}
}

View file

@ -0,0 +1,11 @@
export class enableChartsForFederatedInstances1679652081809 {
name = 'enableChartsForFederatedInstances1679652081809'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForFederatedInstances" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForFederatedInstances"`);
}
}

View file

@ -37,6 +37,9 @@
"@tensorflow/tfjs-node": "4.2.0" "@tensorflow/tfjs-node": "4.2.0"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.294.0",
"@aws-sdk/lib-storage": "^3.294.0",
"@aws-sdk/node-http-handler": "^3.292.0",
"@bull-board/api": "5.0.0", "@bull-board/api": "5.0.0",
"@bull-board/fastify": "5.0.0", "@bull-board/fastify": "5.0.0",
"@bull-board/ui": "5.0.0", "@bull-board/ui": "5.0.0",
@ -59,7 +62,6 @@
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "5.3.1", "archiver": "5.3.1",
"autwh": "0.1.0", "autwh": "0.1.0",
"aws-sdk": "2.1318.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"bull": "4.10.4", "bull": "4.10.4",
@ -190,6 +192,7 @@
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.54.1", "@typescript-eslint/eslint-plugin": "5.54.1",
"@typescript-eslint/parser": "5.54.1", "@typescript-eslint/parser": "5.54.1",
"aws-sdk-client-mock": "^2.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.35.0", "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",

View file

@ -71,12 +71,14 @@ export class AntennaService implements OnApplicationShutdown {
this.antennas.push({ this.antennas.push({
...body, ...body,
createdAt: new Date(body.createdAt), createdAt: new Date(body.createdAt),
lastUsedAt: new Date(body.lastUsedAt),
}); });
break; break;
case 'antennaUpdated': case 'antennaUpdated':
this.antennas[this.antennas.findIndex(a => a.id === body.id)] = { this.antennas[this.antennas.findIndex(a => a.id === body.id)] = {
...body, ...body,
createdAt: new Date(body.createdAt), createdAt: new Date(body.createdAt),
lastUsedAt: new Date(body.lastUsedAt),
}; };
break; break;
case 'antennaDeleted': case 'antennaDeleted':
@ -217,7 +219,9 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis @bindThis
public async getAntennas() { public async getAntennas() {
if (!this.antennasFetched) { if (!this.antennasFetched) {
this.antennas = await this.antennasRepository.find(); this.antennas = await this.antennasRepository.findBy({
isActive: true,
});
this.antennasFetched = true; this.antennasFetched = true;
} }

View file

@ -8,7 +8,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Emoji } from '@/models/entities/Emoji.js'; import type { Emoji } from '@/models/entities/Emoji.js';
import type { EmojisRepository, Note } from '@/models/index.js'; import type { EmojisRepository, Note } from '@/models/index.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { ReactionService } from '@/core/ReactionService.js'; import { ReactionService } from '@/core/ReactionService.js';
@ -16,7 +16,7 @@ import { query } from '@/misc/prelude/url.js';
@Injectable() @Injectable()
export class CustomEmojiService { export class CustomEmojiService {
private cache: Cache<Emoji | null>; private cache: KVCache<Emoji | null>;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -34,7 +34,7 @@ export class CustomEmojiService {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private reactionService: ReactionService, private reactionService: ReactionService,
) { ) {
this.cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12); this.cache = new KVCache<Emoji | null>(1000 * 60 * 60 * 12);
} }
@bindThis @bindThis

View file

@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid';
import sharp from 'sharp'; import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp'; import { sharpBmp } from 'sharp-read-bmp';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -36,7 +37,6 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { correctFilename } from '@/misc/correct-filename.js'; import { correctFilename } from '@/misc/correct-filename.js';
import { isMimeImage } from '@/misc/is-mime-image.js'; import { isMimeImage } from '@/misc/is-mime-image.js';
import type S3 from 'aws-sdk/clients/s3.js';
type AddFileArgs = { type AddFileArgs = {
/** User who wish to add file */ /** User who wish to add file */
@ -81,6 +81,7 @@ type UploadFromUrlArgs = {
export class DriveService { export class DriveService {
private registerLogger: Logger; private registerLogger: Logger;
private downloaderLogger: Logger; private downloaderLogger: Logger;
private deleteLogger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -118,6 +119,7 @@ export class DriveService {
const logger = new Logger('drive', 'blue'); const logger = new Logger('drive', 'blue');
this.registerLogger = logger.createSubLogger('register', 'yellow'); this.registerLogger = logger.createSubLogger('register', 'yellow');
this.downloaderLogger = logger.createSubLogger('downloader'); this.downloaderLogger = logger.createSubLogger('downloader');
this.deleteLogger = logger.createSubLogger('delete');
} }
/*** /***
@ -368,7 +370,7 @@ export class DriveService {
Body: stream, Body: stream,
ContentType: type, ContentType: type,
CacheControl: 'max-age=31536000, immutable', CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest; } as PutObjectCommandInput;
if (filename) params.ContentDisposition = contentDisposition( if (filename) params.ContentDisposition = contentDisposition(
'inline', 'inline',
@ -378,21 +380,16 @@ export class DriveService {
); );
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = this.s3Service.getS3(meta); await this.s3Service.upload(meta, params)
const upload = s3.upload(params, {
partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
});
await upload.promise()
.then( .then(
result => { result => {
if (result) { if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
} else { } else { // AbortMultipartUploadCommandOutput
this.registerLogger.error(`Upload Result Empty: key = ${key}, filename = ${filename}`); this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
} }
}, })
.catch(
err => { err => {
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
}, },
@ -622,12 +619,15 @@ export class DriveService {
}); });
} }
// 統計を更新
this.driveChart.update(file, true); this.driveChart.update(file, true);
if (file.userHost == null) {
// ローカルユーザーのみ
this.perUserDriveChart.update(file, true); this.perUserDriveChart.update(file, true);
if (file.userHost !== null) { } else {
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateDrive(file, true); this.instanceChart.updateDrive(file, true);
} }
}
return file; return file;
} }
@ -709,35 +709,38 @@ export class DriveService {
this.driveFilesRepository.delete(file.id); this.driveFilesRepository.delete(file.id);
} }
// 統計を更新
this.driveChart.update(file, false); this.driveChart.update(file, false);
if (file.userHost == null) {
// ローカルユーザーのみ
this.perUserDriveChart.update(file, false); this.perUserDriveChart.update(file, false);
if (file.userHost !== null) { } else {
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateDrive(file, false); this.instanceChart.updateDrive(file, false);
} }
} }
}
@bindThis @bindThis
public async deleteObjectStorageFile(key: string) { public async deleteObjectStorageFile(key: string) {
const meta = await this.metaService.fetch(); const meta = await this.metaService.fetch();
const s3 = this.s3Service.getS3(meta);
try { try {
await s3.deleteObject({ const param = {
Bucket: meta.objectStorageBucket!, Bucket: meta.objectStorageBucket,
Key: key, Key: key,
}).promise(); } as DeleteObjectCommandInput;
await this.s3Service.delete(meta, param);
} catch (err: any) { } catch (err: any) {
if (err.code === 'NoSuchKey') { if (err.name === 'NoSuchKey') {
console.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err); this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
return; return;
} } else {
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
cause: err, cause: err,
}); });
} }
} }
}
@bindThis @bindThis
public async uploadFromUrl({ public async uploadFromUrl({

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { InstancesRepository } from '@/models/index.js'; import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
@ -9,7 +9,7 @@ import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
export class FederatedInstanceService { export class FederatedInstanceService {
private cache: Cache<Instance>; private cache: KVCache<Instance>;
constructor( constructor(
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
@ -18,7 +18,7 @@ export class FederatedInstanceService {
private utilityService: UtilityService, private utilityService: UtilityService,
private idService: IdService, private idService: IdService,
) { ) {
this.cache = new Cache<Instance>(1000 * 60 * 60); this.cache = new KVCache<Instance>(1000 * 60 * 60);
} }
@bindThis @bindThis

View file

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
import type { LocalUser } from '@/models/entities/User.js'; import type { LocalUser } from '@/models/entities/User.js';
import type { UsersRepository } from '@/models/index.js'; import type { UsersRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const;
@Injectable() @Injectable()
export class InstanceActorService { export class InstanceActorService {
private cache: Cache<LocalUser>; private cache: KVCache<LocalUser>;
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
@ -19,7 +19,7 @@ export class InstanceActorService {
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
) { ) {
this.cache = new Cache<LocalUser>(Infinity); this.cache = new KVCache<LocalUser>(Infinity);
} }
@bindThis @bindThis

View file

@ -19,7 +19,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js
import { checkWordMute } from '@/misc/check-word-mute.js'; import { checkWordMute } from '@/misc/check-word-mute.js';
import type { Channel } from '@/models/entities/Channel.js'; import type { Channel } from '@/models/entities/Channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { UserProfile } from '@/models/entities/UserProfile.js'; import type { UserProfile } from '@/models/entities/UserProfile.js';
import { RelayService } from '@/core/RelayService.js'; import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -46,7 +46,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); const mutedWordsCache = new KVCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -435,15 +435,20 @@ export class NoteCreateService implements OnApplicationShutdown {
createdAt: User['createdAt']; createdAt: User['createdAt'];
isBot: User['isBot']; isBot: User['isBot'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
// 統計を更新 const meta = await this.metaService.fetch();
this.notesChart.update(note, true); this.notesChart.update(note, true);
if (meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserNotesChart.update(user, note, true); this.perUserNotesChart.update(user, note, true);
}
// Register host // Register host
if (this.userEntityService.isRemoteUser(user)) { if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(i => { this.federatedInstanceService.fetch(user.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true); this.instanceChart.updateNote(i.host, note, true);
}
}); });
} }

View file

@ -16,6 +16,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
@Injectable() @Injectable()
export class NoteDeleteService { export class NoteDeleteService {
@ -39,6 +40,7 @@ export class NoteDeleteService {
private federatedInstanceService: FederatedInstanceService, private federatedInstanceService: FederatedInstanceService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
private metaService: MetaService,
private notesChart: NotesChart, private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart, private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
@ -95,14 +97,19 @@ export class NoteDeleteService {
} }
//#endregion //#endregion
// 統計を更新 const meta = await this.metaService.fetch();
this.notesChart.update(note, false); this.notesChart.update(note, false);
if (meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserNotesChart.update(user, note, false); this.perUserNotesChart.update(user, note, false);
}
if (this.userEntityService.isRemoteUser(user)) { if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(i => { this.federatedInstanceService.fetch(user.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false); this.instanceChart.updateNote(i.host, note, false);
}
}); });
} }
} }

View file

@ -21,6 +21,8 @@ import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
const FALLBACK = '❤';
const legacies: Record<string, string> = { const legacies: Record<string, string> = {
'like': '👍', 'like': '👍',
'love': '❤', // ここに記述する場合は異体字セレクタを入れない 'love': '❤', // ここに記述する場合は異体字セレクタを入れない
@ -147,7 +149,11 @@ export class ReactionService {
.where('id = :id', { id: note.id }) .where('id = :id', { id: note.id })
.execute(); .execute();
const meta = await this.metaService.fetch();
if (meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserReactionsChart.update(user, note); this.perUserReactionsChart.update(user, note);
}
// カスタム絵文字リアクションだったら絵文字情報も送る // カスタム絵文字リアクションだったら絵文字情報も送る
const decodedReaction = this.decodeReaction(reaction); const decodedReaction = this.decodeReaction(reaction);
@ -251,12 +257,6 @@ export class ReactionService {
//#endregion //#endregion
} }
@bindThis
public async getFallbackReaction(): Promise<string> {
const meta = await this.metaService.fetch();
return meta.useStarForReactionFallback ? '⭐' : '👍';
}
@bindThis @bindThis
public convertLegacyReactions(reactions: Record<string, number>) { public convertLegacyReactions(reactions: Record<string, number>) {
const _reactions = {} as Record<string, number>; const _reactions = {} as Record<string, number>;
@ -290,7 +290,7 @@ export class ReactionService {
@bindThis @bindThis
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
if (reaction == null) return await this.getFallbackReaction(); if (reaction == null) return FALLBACK;
reacterHost = this.utilityService.toPunyNullable(reacterHost); reacterHost = this.utilityService.toPunyNullable(reacterHost);
@ -318,7 +318,7 @@ export class ReactionService {
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
} }
return await this.getFallbackReaction(); return FALLBACK;
} }
@bindThis @bindThis

View file

@ -3,7 +3,7 @@ import { IsNull } from 'typeorm';
import type { LocalUser, User } from '@/models/entities/User.js'; import type { LocalUser, User } from '@/models/entities/User.js';
import type { RelaysRepository, UsersRepository } from '@/models/index.js'; import type { RelaysRepository, UsersRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { Relay } from '@/models/entities/Relay.js'; import type { Relay } from '@/models/entities/Relay.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const;
@Injectable() @Injectable()
export class RelayService { export class RelayService {
private relaysCache: Cache<Relay[]>; private relaysCache: KVCache<Relay[]>;
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
@ -30,7 +30,7 @@ export class RelayService {
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
) { ) {
this.relaysCache = new Cache<Relay[]>(1000 * 60 * 10); this.relaysCache = new KVCache<Relay[]>(1000 * 60 * 10);
} }
@bindThis @bindThis

View file

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { In } from 'typeorm'; import { In } from 'typeorm';
import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -57,8 +57,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable() @Injectable()
export class RoleService implements OnApplicationShutdown { export class RoleService implements OnApplicationShutdown {
private rolesCache: Cache<Role[]>; private rolesCache: KVCache<Role[]>;
private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>; private roleAssignmentByUserIdCache: KVCache<RoleAssignment[]>;
public static AlreadyAssignedError = class extends Error {}; public static AlreadyAssignedError = class extends Error {};
public static NotAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {};
@ -84,8 +84,8 @@ export class RoleService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.rolesCache = new Cache<Role[]>(Infinity); this.rolesCache = new KVCache<Role[]>(Infinity);
this.roleAssignmentByUserIdCache = new Cache<RoleAssignment[]>(Infinity); this.roleAssignmentByUserIdCache = new KVCache<RoleAssignment[]>(Infinity);
this.redisSubscriber.on('message', this.onMessage); this.redisSubscriber.on('message', this.onMessage);
} }
@ -192,6 +192,12 @@ export class RoleService implements OnApplicationShutdown {
case 'followingMoreThanOrEq': { case 'followingMoreThanOrEq': {
return user.followingCount >= value.value; return user.followingCount >= value.value;
} }
case 'notesLessThanOrEq': {
return user.notesCount <= value.value;
}
case 'notesMoreThanOrEq': {
return user.notesCount >= value.value;
}
default: default:
return false; return false;
} }

View file

@ -1,11 +1,16 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import * as http from 'node:http';
import * as https from 'node:https';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import S3 from 'aws-sdk/clients/s3.js'; import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { NodeHttpHandler, NodeHttpHandlerOptions } from '@aws-sdk/node-http-handler';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { Meta } from '@/models/entities/Meta.js'; import type { Meta } from '@/models/entities/Meta.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3';
@Injectable() @Injectable()
export class S3Service { export class S3Service {
@ -18,25 +23,47 @@ export class S3Service {
} }
@bindThis @bindThis
public getS3(meta: Meta) { public getS3Client(meta: Meta): S3Client {
const u = meta.objectStorageEndpoint const u = meta.objectStorageEndpoint
? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}`
: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent
return new S3({ const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy);
endpoint: meta.objectStorageEndpoint && meta.objectStorageEndpoint.length > 0 const handlerOption: NodeHttpHandlerOptions = {};
? meta.objectStorageEndpoint if (meta.objectStorageUseSSL) {
: undefined, handlerOption.httpsAgent = agent as https.Agent;
accessKeyId: meta.objectStorageAccessKey!, } else {
secretAccessKey: meta.objectStorageSecretKey!, handlerOption.httpAgent = agent as http.Agent;
}
return new S3Client({
endpoint: meta.objectStorageEndpoint ? u : undefined,
credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? {
accessKeyId: meta.objectStorageAccessKey,
secretAccessKey: meta.objectStorageSecretKey,
} : undefined,
region: meta.objectStorageRegion ?? undefined, region: meta.objectStorageRegion ?? undefined,
sslEnabled: meta.objectStorageUseSSL, tls: meta.objectStorageUseSSL,
s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted
? false requestHandler: new NodeHttpHandler(handlerOption),
: meta.objectStorageS3ForcePathStyle,
httpOptions: {
agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
},
}); });
} }
@bindThis
public async upload(meta: Meta, input: PutObjectCommandInput) {
const client = this.getS3Client(meta);
return new Upload({
client,
params: input,
partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com')
? 500 * 1024 * 1024
: 8 * 1024 * 1024,
}).done();
}
@bindThis
public delete(meta: Meta, input: DeleteObjectCommandInput) {
const client = this.getS3Client(meta);
return client.send(new DeleteObjectCommand(input));
}
} }

View file

@ -15,7 +15,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { WebhookService } from '@/core/WebhookService.js'; import { WebhookService } from '@/core/WebhookService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import { StreamMessages } from '@/server/api/stream/types.js';
@Injectable() @Injectable()
@ -23,7 +23,7 @@ export class UserBlockingService implements OnApplicationShutdown {
private logger: Logger; private logger: Logger;
// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ // キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ
private blockingsByUserIdCache: Cache<User['id'][]>; private blockingsByUserIdCache: KVCache<User['id'][]>;
constructor( constructor(
@Inject(DI.redisSubscriber) @Inject(DI.redisSubscriber)
@ -58,7 +58,7 @@ export class UserBlockingService implements OnApplicationShutdown {
) { ) {
this.logger = this.loggerService.getLogger('user-block'); this.logger = this.loggerService.getLogger('user-block');
this.blockingsByUserIdCache = new Cache<User['id'][]>(Infinity); this.blockingsByUserIdCache = new KVCache<User['id'][]>(Infinity);
this.redisSubscriber.on('message', this.onMessage); this.redisSubscriber.on('message', this.onMessage);
} }

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis'; import Redis from 'ioredis';
import type { UsersRepository } from '@/models/index.js'; import type { UsersRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { LocalUser, User } from '@/models/entities/User.js'; import type { LocalUser, User } from '@/models/entities/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -11,10 +11,10 @@ import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
export class UserCacheService implements OnApplicationShutdown { export class UserCacheService implements OnApplicationShutdown {
public userByIdCache: Cache<User>; public userByIdCache: KVCache<User>;
public localUserByNativeTokenCache: Cache<LocalUser | null>; public localUserByNativeTokenCache: KVCache<LocalUser | null>;
public localUserByIdCache: Cache<LocalUser>; public localUserByIdCache: KVCache<LocalUser>;
public uriPersonCache: Cache<User | null>; public uriPersonCache: KVCache<User | null>;
constructor( constructor(
@Inject(DI.redisSubscriber) @Inject(DI.redisSubscriber)
@ -27,10 +27,10 @@ export class UserCacheService implements OnApplicationShutdown {
) { ) {
//this.onMessage = this.onMessage.bind(this); //this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new Cache<User>(Infinity); this.userByIdCache = new KVCache<User>(Infinity);
this.localUserByNativeTokenCache = new Cache<LocalUser | null>(Infinity); this.localUserByNativeTokenCache = new KVCache<LocalUser | null>(Infinity);
this.localUserByIdCache = new Cache<LocalUser>(Infinity); this.localUserByIdCache = new KVCache<LocalUser>(Infinity);
this.uriPersonCache = new Cache<User | null>(Infinity); this.uriPersonCache = new KVCache<User | null>(Infinity);
this.redisSubscriber.on('message', this.onMessage); this.redisSubscriber.on('message', this.onMessage);
} }

View file

@ -17,6 +17,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserBlockingService } from '@/core/UserBlockingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js';
import { MetaService } from '@/core/MetaService.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -57,6 +58,7 @@ export class UserFollowingService {
private idService: IdService, private idService: IdService,
private queueService: QueueService, private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private metaService: MetaService,
private notificationService: NotificationService, private notificationService: NotificationService,
private federatedInstanceService: FederatedInstanceService, private federatedInstanceService: FederatedInstanceService,
private webhookService: WebhookService, private webhookService: WebhookService,
@ -200,14 +202,18 @@ export class UserFollowingService {
//#region Update instance stats //#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(i => { this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, true); this.instanceChart.updateFollowing(i.host, true);
}
}); });
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(i => { this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true); this.instanceChart.updateFollowers(i.host, true);
}
}); });
} }
//#endregion //#endregion
@ -320,14 +326,18 @@ export class UserFollowingService {
//#region Update instance stats //#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(follower.host).then(i => { this.federatedInstanceService.fetch(follower.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowing(i.host, false); this.instanceChart.updateFollowing(i.host, false);
}
}); });
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
this.federatedInstanceService.fetch(followee.host).then(i => { this.federatedInstanceService.fetch(followee.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false); this.instanceChart.updateFollowers(i.host, false);
}
}); });
} }
//#endregion //#endregion

View file

@ -1,20 +1,20 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import type { UserKeypairsRepository } from '@/models/index.js'; import type { UserKeypairsRepository } from '@/models/index.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { UserKeypair } from '@/models/entities/UserKeypair.js'; import type { UserKeypair } from '@/models/entities/UserKeypair.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@Injectable() @Injectable()
export class UserKeypairStoreService { export class UserKeypairStoreService {
private cache: Cache<UserKeypair>; private cache: KVCache<UserKeypair>;
constructor( constructor(
@Inject(DI.userKeypairsRepository) @Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository, private userKeypairsRepository: UserKeypairsRepository,
) { ) {
this.cache = new Cache<UserKeypair>(Infinity); this.cache = new KVCache<UserKeypair>(Infinity);
} }
@bindThis @bindThis

View file

@ -3,7 +3,7 @@ import escapeRegexp from 'escape-regexp';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { UserPublickey } from '@/models/entities/UserPublickey.js'; import type { UserPublickey } from '@/models/entities/UserPublickey.js';
import { UserCacheService } from '@/core/UserCacheService.js'; import { UserCacheService } from '@/core/UserCacheService.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
@ -31,8 +31,8 @@ export type UriParseResult = {
@Injectable() @Injectable()
export class ApDbResolverService { export class ApDbResolverService {
private publicKeyCache: Cache<UserPublickey | null>; private publicKeyCache: KVCache<UserPublickey | null>;
private publicKeyByUserIdCache: Cache<UserPublickey | null>; private publicKeyByUserIdCache: KVCache<UserPublickey | null>;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -50,8 +50,8 @@ export class ApDbResolverService {
private userCacheService: UserCacheService, private userCacheService: UserCacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
) { ) {
this.publicKeyCache = new Cache<UserPublickey | null>(Infinity); this.publicKeyCache = new KVCache<UserPublickey | null>(Infinity);
this.publicKeyByUserIdCache = new Cache<UserPublickey | null>(Infinity); this.publicKeyByUserIdCache = new KVCache<UserPublickey | null>(Infinity);
} }
@bindThis @bindThis

View file

@ -30,6 +30,7 @@ import { StatusError } from '@/misc/status-error.js';
import type { UtilityService } from '@/core/UtilityService.js'; import type { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -50,6 +51,7 @@ export class ApPersonService implements OnModuleInit {
private userEntityService: UserEntityService; private userEntityService: UserEntityService;
private idService: IdService; private idService: IdService;
private globalEventService: GlobalEventService; private globalEventService: GlobalEventService;
private metaService: MetaService;
private federatedInstanceService: FederatedInstanceService; private federatedInstanceService: FederatedInstanceService;
private fetchInstanceMetadataService: FetchInstanceMetadataService; private fetchInstanceMetadataService: FetchInstanceMetadataService;
private userCacheService: UserCacheService; private userCacheService: UserCacheService;
@ -92,6 +94,7 @@ export class ApPersonService implements OnModuleInit {
//private userEntityService: UserEntityService, //private userEntityService: UserEntityService,
//private idService: IdService, //private idService: IdService,
//private globalEventService: GlobalEventService, //private globalEventService: GlobalEventService,
//private metaService: MetaService,
//private federatedInstanceService: FederatedInstanceService, //private federatedInstanceService: FederatedInstanceService,
//private fetchInstanceMetadataService: FetchInstanceMetadataService, //private fetchInstanceMetadataService: FetchInstanceMetadataService,
//private userCacheService: UserCacheService, //private userCacheService: UserCacheService,
@ -112,6 +115,7 @@ export class ApPersonService implements OnModuleInit {
this.userEntityService = this.moduleRef.get('UserEntityService'); this.userEntityService = this.moduleRef.get('UserEntityService');
this.idService = this.moduleRef.get('IdService'); this.idService = this.moduleRef.get('IdService');
this.globalEventService = this.moduleRef.get('GlobalEventService'); this.globalEventService = this.moduleRef.get('GlobalEventService');
this.metaService = this.moduleRef.get('MetaService');
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
this.userCacheService = this.moduleRef.get('UserCacheService'); this.userCacheService = this.moduleRef.get('UserCacheService');
@ -327,10 +331,12 @@ export class ApPersonService implements OnModuleInit {
} }
// Register host // Register host
this.federatedInstanceService.fetch(host).then(i => { this.federatedInstanceService.fetch(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.instanceChart.newUser(i.host);
this.fetchInstanceMetadataService.fetchInstanceMetadata(i); this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
}); });
this.usersChart.update(user!, true); this.usersChart.update(user!, true);

View file

@ -37,6 +37,7 @@ export class AntennaEntityService {
notify: antenna.notify, notify: antenna.notify,
withReplies: antenna.withReplies, withReplies: antenna.withReplies,
withFile: antenna.withFile, withFile: antenna.withFile,
isActive: antenna.isActive,
hasUnreadNote, hasUnreadNote,
}; };
} }

View file

@ -8,7 +8,7 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js'; import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
@ -52,7 +52,7 @@ export class UserEntityService implements OnModuleInit {
private customEmojiService: CustomEmojiService; private customEmojiService: CustomEmojiService;
private antennaService: AntennaService; private antennaService: AntennaService;
private roleService: RoleService; private roleService: RoleService;
private userInstanceCache: Cache<Instance | null>; private userInstanceCache: KVCache<Instance | null>;
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@ -121,7 +121,7 @@ export class UserEntityService implements OnModuleInit {
//private antennaService: AntennaService, //private antennaService: AntennaService,
//private roleService: RoleService, //private roleService: RoleService,
) { ) {
this.userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3); this.userInstanceCache = new KVCache<Instance | null>(1000 * 60 * 60 * 3);
} }
onModuleInit() { onModuleInit() {

View file

@ -2,11 +2,11 @@ import { bindThis } from '@/decorators.js';
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class Cache<T> { export class KVCache<T> {
public cache: Map<string | null, { date: number; value: T; }>; public cache: Map<string | null, { date: number; value: T; }>;
private lifetime: number; private lifetime: number;
constructor(lifetime: Cache<never>['lifetime']) { constructor(lifetime: KVCache<never>['lifetime']) {
this.cache = new Map(); this.cache = new Map();
this.lifetime = lifetime; this.lifetime = lifetime;
} }
@ -87,3 +87,88 @@ export class Cache<T> {
return value; return value;
} }
} }
export class Cache<T> {
private cachedAt: number | null = null;
private value: T | undefined;
private lifetime: number;
constructor(lifetime: Cache<never>['lifetime']) {
this.lifetime = lifetime;
}
@bindThis
public set(value: T): void {
this.cachedAt = Date.now();
this.value = value;
}
@bindThis
public get(): T | undefined {
if (this.cachedAt == null) return undefined;
if ((Date.now() - this.cachedAt) > this.lifetime) {
this.value = undefined;
this.cachedAt = null;
return undefined;
}
return this.value;
}
@bindThis
public delete() {
this.value = undefined;
this.cachedAt = null;
}
/**
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
@bindThis
public async fetch(fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cachedValue = this.get();
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
this.set(value);
return value;
}
/**
* fetcherを呼び出して結果をキャッシュ&
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
*/
@bindThis
public async fetchMaybe(fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get();
if (cachedValue !== undefined) {
if (validator) {
if (validator(cachedValue)) {
// Cache HIT
return cachedValue;
}
} else {
// Cache HIT
return cachedValue;
}
}
// Cache MISS
const value = await fetcher();
if (value !== undefined) {
this.set(value);
}
return value;
}
}

View file

@ -13,6 +13,10 @@ export class Antenna {
}) })
public createdAt: Date; public createdAt: Date;
@Index()
@Column('timestamp with time zone')
public lastUsedAt: Date;
@Index() @Index()
@Column({ @Column({
...id(), ...id(),
@ -83,4 +87,10 @@ export class Antenna {
@Column('boolean') @Column('boolean')
public notify: boolean; public notify: boolean;
@Index()
@Column('boolean', {
default: true,
})
public isActive: boolean;
} }

View file

@ -42,11 +42,6 @@ export class Meta {
}) })
public disableRegistration: boolean; public disableRegistration: boolean;
@Column('boolean', {
default: false,
})
public useStarForReactionFallback: boolean;
@Column('varchar', { @Column('varchar', {
length: 1024, array: true, default: '{}', length: 1024, array: true, default: '{}',
}) })
@ -396,6 +391,16 @@ export class Meta {
}) })
public enableActiveEmailValidation: boolean; public enableActiveEmailValidation: boolean;
@Column('boolean', {
default: true,
})
public enableChartsForRemoteUser: boolean;
@Column('boolean', {
default: true,
})
public enableChartsForFederatedInstances: boolean;
@Column('jsonb', { @Column('jsonb', {
default: { }, default: { },
}) })

View file

@ -54,6 +54,16 @@ type CondFormulaValueFollowingMoreThanOrEq = {
value: number; value: number;
}; };
type CondFormulaValueNotesLessThanOrEq = {
type: 'notesLessThanOrEq';
value: number;
};
type CondFormulaValueNotesMoreThanOrEq = {
type: 'notesMoreThanOrEq';
value: number;
};
export type RoleCondFormulaValue = export type RoleCondFormulaValue =
CondFormulaValueAnd | CondFormulaValueAnd |
CondFormulaValueOr | CondFormulaValueOr |
@ -65,7 +75,9 @@ export type RoleCondFormulaValue =
CondFormulaValueFollowersLessThanOrEq | CondFormulaValueFollowersLessThanOrEq |
CondFormulaValueFollowersMoreThanOrEq | CondFormulaValueFollowersMoreThanOrEq |
CondFormulaValueFollowingLessThanOrEq | CondFormulaValueFollowingLessThanOrEq |
CondFormulaValueFollowingMoreThanOrEq; CondFormulaValueFollowingMoreThanOrEq |
CondFormulaValueNotesLessThanOrEq |
CondFormulaValueNotesMoreThanOrEq;
@Entity() @Entity()
export class Role { export class Role {

View file

@ -75,6 +75,10 @@ export const packedAntennaSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isActive: {
type: 'boolean',
optional: false, nullable: false,
},
hasUnreadNote: { hasUnreadNote: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In, LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { AntennaNotesRepository, AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -26,6 +26,9 @@ export class CleanProcessorService {
@Inject(DI.mutedNotesRepository) @Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository, private mutedNotesRepository: MutedNotesRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.antennaNotesRepository) @Inject(DI.antennaNotesRepository)
private antennaNotesRepository: AntennaNotesRepository, private antennaNotesRepository: AntennaNotesRepository,
@ -55,8 +58,16 @@ export class CleanProcessorService {
reason: 'word', reason: 'word',
}); });
this.antennaNotesRepository.delete({ this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word',
});
// 7日以上使われてないアンテナを停止
this.antennasRepository.update({
lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))),
}, {
isActive: false,
}); });
const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign') const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')

View file

@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import InstanceChart from '@/core/chart/charts/instance.js'; import InstanceChart from '@/core/chart/charts/instance.js';
import ApRequestChart from '@/core/chart/charts/ap-request.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js';
@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js';
@Injectable() @Injectable()
export class DeliverProcessorService { export class DeliverProcessorService {
private logger: Logger; private logger: Logger;
private suspendedHostsCache: Cache<Instance[]>; private suspendedHostsCache: KVCache<Instance[]>;
private latest: string | null; private latest: string | null;
constructor( constructor(
@ -46,7 +46,7 @@ export class DeliverProcessorService {
private queueLoggerService: QueueLoggerService, private queueLoggerService: QueueLoggerService,
) { ) {
this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); this.logger = this.queueLoggerService.logger.createSubLogger('deliver');
this.suspendedHostsCache = new Cache<Instance[]>(1000 * 60 * 60); this.suspendedHostsCache = new KVCache<Instance[]>(1000 * 60 * 60);
} }
@bindThis @bindThis
@ -88,10 +88,12 @@ export class DeliverProcessorService {
} }
this.fetchInstanceMetadataService.fetchInstanceMetadata(i); this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.instanceChart.requestSent(i.host, true);
this.apRequestChart.deliverSucc(); this.apRequestChart.deliverSucc();
this.federationChart.deliverd(i.host, true); this.federationChart.deliverd(i.host, true);
if (meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, true);
}
}); });
return 'Success'; return 'Success';
@ -107,9 +109,12 @@ export class DeliverProcessorService {
}); });
} }
this.instanceChart.requestSent(i.host, false);
this.apRequestChart.deliverFail(); this.apRequestChart.deliverFail();
this.federationChart.deliverd(i.host, false); this.federationChart.deliverd(i.host, false);
if (meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, false);
}
}); });
if (res instanceof StatusError) { if (res instanceof StatusError) {

View file

@ -184,9 +184,12 @@ export class InboxProcessorService {
this.fetchInstanceMetadataService.fetchInstanceMetadata(i); this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.instanceChart.requestReceived(i.host);
this.apRequestChart.inbox(); this.apRequestChart.inbox();
this.federationChart.inbox(i.host); this.federationChart.inbox(i.host);
if (meta.enableChartsForFederatedInstances) {
this.instanceChart.requestReceived(i.host);
}
}); });
// アクティビティを処理 // アクティビティを処理

View file

@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import NotesChart from '@/core/chart/charts/notes.js'; import NotesChart from '@/core/chart/charts/notes.js';
@ -118,7 +118,7 @@ export class NodeinfoServerService {
}; };
}; };
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); const cache = new KVCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
fastify.get(nodeinfo2_1path, async (request, reply) => { fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(null, () => nodeinfo2()); const base = await cache.fetch(null, () => nodeinfo2());

View file

@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js';
import type { LocalUser } from '@/models/entities/User.js'; import type { LocalUser } from '@/models/entities/User.js';
import type { AccessToken } from '@/models/entities/AccessToken.js'; import type { AccessToken } from '@/models/entities/AccessToken.js';
import { Cache } from '@/misc/cache.js'; import { KVCache } from '@/misc/cache.js';
import type { App } from '@/models/entities/App.js'; import type { App } from '@/models/entities/App.js';
import { UserCacheService } from '@/core/UserCacheService.js'; import { UserCacheService } from '@/core/UserCacheService.js';
import isNativeToken from '@/misc/is-native-token.js'; import isNativeToken from '@/misc/is-native-token.js';
@ -18,7 +18,7 @@ export class AuthenticationError extends Error {
@Injectable() @Injectable()
export class AuthenticateService { export class AuthenticateService {
private appCache: Cache<App>; private appCache: KVCache<App>;
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
@ -32,7 +32,7 @@ export class AuthenticateService {
private userCacheService: UserCacheService, private userCacheService: UserCacheService,
) { ) {
this.appCache = new Cache<App>(Infinity); this.appCache = new KVCache<App>(Infinity);
} }
@bindThis @bindThis

View file

@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js';
import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js';
import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js';
import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js';
import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
@ -370,6 +371,7 @@ const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useCla
const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default };
const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default };
const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default };
const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default };
const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default };
const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default };
const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default };
@ -702,6 +704,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_queue_clear, $admin_queue_clear,
$admin_queue_deliverDelayed, $admin_queue_deliverDelayed,
$admin_queue_inboxDelayed, $admin_queue_inboxDelayed,
$admin_queue_promote,
$admin_queue_stats, $admin_queue_stats,
$admin_relays_add, $admin_relays_add,
$admin_relays_list, $admin_relays_list,
@ -1028,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_queue_clear, $admin_queue_clear,
$admin_queue_deliverDelayed, $admin_queue_deliverDelayed,
$admin_queue_inboxDelayed, $admin_queue_inboxDelayed,
$admin_queue_promote,
$admin_queue_stats, $admin_queue_stats,
$admin_relays_add, $admin_relays_add,
$admin_relays_list, $admin_relays_list,

View file

@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js';
import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js';
import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js';
import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js';
import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
@ -368,6 +369,7 @@ const eps = [
['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/clear', ep___admin_queue_clear],
['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed],
['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed],
['admin/queue/promote', ep___admin_queue_promote],
['admin/queue/stats', ep___admin_queue_stats], ['admin/queue/stats', ep___admin_queue_stats],
['admin/relays/add', ep___admin_relays_add], ['admin/relays/add', ep___admin_relays_add],
['admin/relays/list', ep___admin_relays_list], ['admin/relays/list', ep___admin_relays_list],

View file

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource, IsNull } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { EmojisRepository } from '@/models/index.js'; import type { EmojisRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -19,6 +19,11 @@ export const meta = {
code: 'NO_SUCH_EMOJI', code: 'NO_SUCH_EMOJI',
id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8',
}, },
sameNameEmojiExists: {
message: 'Emoji that have same name already exists.',
code: 'SAME_NAME_EMOJI_EXISTS',
id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8',
},
}, },
} as const; } as const;
@ -26,7 +31,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string', format: 'misskey:id' }, id: { type: 'string', format: 'misskey:id' },
name: { type: 'string' }, name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
category: { category: {
type: 'string', type: 'string',
nullable: true, nullable: true,
@ -57,9 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); const emoji = await this.emojisRepository.findOneBy({ id: ps.id });
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: ps.name, host: IsNull() });
if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji);
if (sameNameEmoji != null && sameNameEmoji.id !== ps.id) throw new ApiError(meta.errors.sameNameEmojiExists);
await this.emojisRepository.update(emoji.id, { await this.emojisRepository.update(emoji.id, {
updatedAt: new Date(), updatedAt: new Date(),
name: ps.name, name: ps.name,

View file

@ -239,6 +239,14 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: true, nullable: false, optional: true, nullable: false,
}, },
enableChartsForRemoteUser: {
type: 'boolean',
optional: false, nullable: false,
},
enableChartsForFederatedInstances: {
type: 'boolean',
optional: false, nullable: false,
},
policies: { policies: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
@ -299,7 +307,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
enableServiceWorker: instance.enableServiceWorker, enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.deeplAuthKey != null,
cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteFiles: instance.cacheRemoteFiles,
useStarForReactionFallback: instance.useStarForReactionFallback,
pinnedUsers: instance.pinnedUsers, pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags, hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
@ -337,6 +344,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
deeplIsPro: instance.deeplIsPro, deeplIsPro: instance.deeplIsPro,
enableIpLogging: instance.enableIpLogging, enableIpLogging: instance.enableIpLogging,
enableActiveEmailValidation: instance.enableActiveEmailValidation, enableActiveEmailValidation: instance.enableActiveEmailValidation,
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
policies: { ...DEFAULT_POLICIES, ...instance.policies }, policies: { ...DEFAULT_POLICIES, ...instance.policies },
}; };
}); });

View file

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
type: { type: 'string', enum: ['deliver', 'inbox'] },
},
required: ['type'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
let delayedQueues;
switch (ps.type) {
case 'deliver':
delayedQueues = await this.queueService.deliverQueue.getDelayed();
for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
const queue = delayedQueues[queueIndex];
await queue.promote();
}
break;
case 'inbox':
delayedQueues = await this.queueService.inboxQueue.getDelayed();
for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) {
const queue = delayedQueues[queueIndex];
await queue.promote();
}
break;
}
this.moderationLogService.insertModerationLog(me, 'promoteQueue');
});
}
}

View file

@ -17,7 +17,6 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
disableRegistration: { type: 'boolean', nullable: true }, disableRegistration: { type: 'boolean', nullable: true },
useStarForReactionFallback: { type: 'boolean', nullable: true },
pinnedUsers: { type: 'array', nullable: true, items: { pinnedUsers: { type: 'array', nullable: true, items: {
type: 'string', type: 'string',
} }, } },
@ -93,6 +92,8 @@ export const paramDef = {
objectStorageS3ForcePathStyle: { type: 'boolean' }, objectStorageS3ForcePathStyle: { type: 'boolean' },
enableIpLogging: { type: 'boolean' }, enableIpLogging: { type: 'boolean' },
enableActiveEmailValidation: { type: 'boolean' }, enableActiveEmailValidation: { type: 'boolean' },
enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' },
}, },
required: [], required: [],
} as const; } as const;
@ -114,10 +115,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.disableRegistration = ps.disableRegistration; set.disableRegistration = ps.disableRegistration;
} }
if (typeof ps.useStarForReactionFallback === 'boolean') {
set.useStarForReactionFallback = ps.useStarForReactionFallback;
}
if (Array.isArray(ps.pinnedUsers)) { if (Array.isArray(ps.pinnedUsers)) {
set.pinnedUsers = ps.pinnedUsers.filter(Boolean); set.pinnedUsers = ps.pinnedUsers.filter(Boolean);
} }
@ -382,6 +379,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.enableActiveEmailValidation = ps.enableActiveEmailValidation; set.enableActiveEmailValidation = ps.enableActiveEmailValidation;
} }
if (ps.enableChartsForRemoteUser !== undefined) {
set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser;
}
if (ps.enableChartsForFederatedInstances !== undefined) {
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
}
await this.metaService.update(set); await this.metaService.update(set);
this.moderationLogService.insertModerationLog(me, 'updateMeta'); this.moderationLogService.insertModerationLog(me, 'updateMeta');
}); });

View file

@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.keywords.length === 0) { if ((ps.keywords.length === 0) || ps.keywords[0].every(x => x === '')) {
throw new Error('invalid param'); throw new Error('invalid param');
} }
@ -103,9 +103,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
} }
const now = new Date();
const antenna = await this.antennasRepository.insert({ const antenna = await this.antennasRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
createdAt: new Date(), createdAt: now,
lastUsedAt: now,
userId: me.id, userId: me.id,
name: ps.name, name: ps.name,
src: ps.src, src: ps.src,

View file

@ -101,6 +101,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.noteReadService.read(me.id, notes); this.noteReadService.read(me.id, notes);
} }
this.antennasRepository.update(antenna.id, {
lastUsedAt: new Date(),
});
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });
} }

View file

@ -31,6 +31,7 @@ export const paramDef = {
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, 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'] },
}, },
required: [], required: [],
} as const; } as const;
@ -63,6 +64,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
} }
} }
switch (ps.sort) {
case '+createdAt': query.orderBy('file.createdAt', 'DESC'); break;
case '-createdAt': query.orderBy('file.createdAt', 'ASC'); break;
case '+name': query.orderBy('file.name', 'DESC'); break;
case '-name': query.orderBy('file.name', 'ASC'); break;
case '+size': query.orderBy('file.size', 'DESC'); break;
case '-size': query.orderBy('file.size', 'ASC'); break;
}
const files = await query.take(ps.limit).getMany(); const files = await query.take(ps.limit).getMany();
return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); return await this.driveFileEntityService.packMany(files, { detail: false, self: true });

View file

@ -48,6 +48,7 @@ export const meta = {
message: 'No such user.', message: 'No such user.',
code: 'NO_SUCH_USER', code: 'NO_SUCH_USER',
id: '4362f8dc-731f-4ad8-a694-be5a88922a24', id: '4362f8dc-731f-4ad8-a694-be5a88922a24',
httpStatusCode: 404,
}, },
}, },
} as const; } as const;

View file

@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { summaly } from 'summaly'; import { summaly } from 'summaly';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -9,6 +8,7 @@ import type Logger from '@/logger.js';
import { query } from '@/misc/prelude/url.js'; import { query } from '@/misc/prelude/url.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { ApiError } from '@/server/api/error.js';
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable() @Injectable()
@ -40,9 +40,9 @@ export class UrlPreviewService {
@bindThis @bindThis
public async handle( public async handle(
request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>, request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>,
reply: FastifyReply, reply: FastifyReply,
) { ): Promise<object | undefined> {
const url = request.query.url; const url = request.query.url;
if (typeof url !== 'string') { if (typeof url !== 'string') {
reply.code(400); reply.code(400);
@ -78,7 +78,7 @@ export class UrlPreviewService {
this.logger.succ(`Got preview of ${url}: ${summary.title}`); this.logger.succ(`Got preview of ${url}: ${summary.title}`);
if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
throw new Error('unsupported schema included'); throw new Error('unsupported schema included');
} }
@ -95,9 +95,15 @@ export class UrlPreviewService {
return summary; return summary;
} catch (err) { } catch (err) {
this.logger.warn(`Failed to get preview of ${url}: ${err}`); this.logger.warn(`Failed to get preview of ${url}: ${err}`);
reply.code(200); reply.code(422);
reply.header('Cache-Control', 'max-age=86400, immutable'); reply.header('Cache-Control', 'max-age=86400, immutable');
return {}; return {
error: new ApiError({
message: 'Failed to get preview',
code: 'URL_PREVIEW_FAILED',
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
}),
};
} }
} }
} }

View file

@ -0,0 +1,962 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { JTDDataType } from 'ajv/dist/jtd';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import type { Packed } from '@/misc/json-schema.js';
import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js';
import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js';
import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js';
import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js';
import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js';
import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js';
import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js';
import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js';
import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js';
import {
signup,
post,
startServer,
api,
successfulApiCall,
failedApiCall,
ApiRequest,
hiddenNote,
} from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
describe('クリップ', () => {
type User = Packed<'User'>;
type Note = Packed<'Note'>;
type Clip = Packed<'Clip'>;
let app: INestApplicationContext;
let alice: User;
let bob: User;
let aliceNote: Note;
let aliceHomeNote: Note;
let aliceFollowersNote: Note;
let aliceSpecifiedNote: Note;
let bobNote: Note;
let bobHomeNote: Note;
let bobFollowersNote: Note;
let bobSpecifiedNote: Note;
const compareBy = <T extends { id: string }, >(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
return selector(a).localeCompare(selector(b));
};
type CreateParam = JTDDataType<typeof CreateParamDef>;
const defaultCreate = (): Partial<CreateParam> => ({
name: 'test',
});
const create = async (parameters: Partial<CreateParam> = {}, request: Partial<ApiRequest> = {}): Promise<Clip> => {
const clip = await successfulApiCall<Clip>({
endpoint: '/clips/create',
parameters: {
...defaultCreate(),
...parameters,
},
user: alice,
...request,
});
// 入力が結果として入っていること
assert.deepStrictEqual(clip, {
...clip,
...defaultCreate(),
...parameters,
});
return clip;
};
const createMany = async (parameters: Partial<CreateParam>, count = 10, user = alice): Promise<Clip[]> => {
return await Promise.all([...Array(count)].map((_, i) => create({
name: `test${i}`,
...parameters,
}, { user })));
};
type UpdateParam = JTDDataType<typeof UpdateParamDef>;
const update = async (parameters: Partial<UpdateParam>, request: Partial<ApiRequest> = {}): Promise<Clip> => {
const clip = await successfulApiCall<Clip>({
endpoint: '/clips/update',
parameters: {
name: 'updated',
...parameters,
},
user: alice,
...request,
});
// 入力が結果として入っていること。clipIdはidになるので消しておく
delete (parameters as { clipId?: string }).clipId;
assert.deepStrictEqual(clip, {
...clip,
...parameters,
});
return clip;
};
type DeleteParam = JTDDataType<typeof DeleteParamDef>;
const deleteClip = async (parameters: DeleteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
return await successfulApiCall<void>({
endpoint: '/clips/delete',
parameters,
user: alice,
...request,
}, {
status: 204,
});
};
type ShowParam = JTDDataType<typeof ShowParamDef>;
const show = async (parameters: ShowParam, request: Partial<ApiRequest> = {}): Promise<Clip> => {
return await successfulApiCall<Clip>({
endpoint: '/clips/show',
parameters,
user: alice,
...request,
});
};
const list = async (request: Partial<ApiRequest>): Promise<Clip[]> => {
return successfulApiCall<Clip[]>({
endpoint: '/clips/list',
parameters: {},
user: alice,
...request,
});
};
const usersClips = async (request: Partial<ApiRequest>): Promise<Clip[]> => {
return await successfulApiCall<Clip[]>({
endpoint: '/users/clips',
parameters: {},
user: alice,
...request,
});
};
beforeAll(async () => {
app = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
// FIXME: misskey-jsのNoteはoutdatedなので直接変換できない
aliceNote = await post(alice, { text: 'test' }) as any;
aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any;
aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any;
aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any;
bobNote = await post(bob, { text: 'test' }) as any;
bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any;
bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any;
bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any;
}, 1000 * 60 * 2);
afterAll(async () => {
await app.close();
});
afterEach(async () => {
// テスト間で影響し合わないように毎回全部消す。
for (const user of [alice, bob]) {
const list = await api('/clips/list', { limit: 11 }, user);
for (const clip of list.body) {
await api('/clips/delete', { clipId: clip.id }, user);
}
}
});
test('の作成ができる', async () => {
const res = await create();
// ISO 8601で日付が返ってくること
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
assert.strictEqual(res.lastClippedAt, null);
assert.strictEqual(res.name, 'test');
assert.strictEqual(res.description, null);
assert.strictEqual(res.isPublic, false);
assert.strictEqual(res.favoritedCount, 0);
assert.strictEqual(res.isFavorited, false);
});
test('の作成はポリシーで定められた数以上はできない。', async () => {
// ポリシー + 1まで作れるという所がミソ
const clipLimit = DEFAULT_POLICIES.clipLimit + 1;
for (let i = 0; i < clipLimit; i++) {
await create();
}
await failedApiCall({
endpoint: '/clips/create',
parameters: defaultCreate(),
user: alice,
}, {
status: 400,
code: 'TOO_MANY_CLIPS',
id: '920f7c2d-6208-4b76-8082-e632020f5883',
});
});
const createClipAllowedPattern = [
{ label: 'nameが最大長', parameters: { name: 'x'.repeat(100) } },
{ label: 'private', parameters: { isPublic: false } },
{ label: 'public', parameters: { isPublic: true } },
{ label: 'descriptionがnull', parameters: { description: null } },
{ label: 'descriptionが最大長', parameters: { description: 'a'.repeat(2048) } },
];
test.each(createClipAllowedPattern)('の作成は$labelでもできる', async ({ parameters }) => await create(parameters));
const createClipDenyPattern = [
{ label: 'nameがnull', parameters: { name: null } },
{ label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } },
{ label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } },
{ label: 'descriptionがゼロ長', parameters: { description: '' } },
{ label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } },
];
test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({
endpoint: '/clips/create',
parameters: {
...defaultCreate(),
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
}));
test('の更新ができる', async () => {
const res = await update({
clipId: (await create()).id,
name: 'updated',
description: 'new description',
isPublic: true,
});
// ISO 8601で日付が返ってくること
assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString());
assert.strictEqual(res.lastClippedAt, null);
assert.strictEqual(res.name, 'updated');
assert.strictEqual(res.description, 'new description');
assert.strictEqual(res.isPublic, true);
assert.strictEqual(res.favoritedCount, 0);
assert.strictEqual(res.isFavorited, false);
});
test.each(createClipAllowedPattern)('の更新は$labelでもできる', async ({ parameters }) => await update({
clipId: (await create()).id,
name: 'updated',
...parameters,
}));
test.each([
{ label: 'clipIdがnull', parameters: { clipId: null } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
code: 'NO_SUCH_CLIP',
id: 'b4d92d70-b216-46fa-9a3f-a8c811699257',
} },
{ label: '他人のクリップ', user: (): User => bob, assertion: {
code: 'NO_SUCH_CLIP',
id: 'b4d92d70-b216-46fa-9a3f-a8c811699257',
} },
...createClipDenyPattern as any,
])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
endpoint: '/clips/update',
parameters: {
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
name: 'updated',
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assertion,
}));
test('の削除ができる', async () => {
await deleteClip({
clipId: (await create()).id,
});
assert.deepStrictEqual(await list({}), []);
});
test.each([
{ label: 'clipIdがnull', parameters: { clipId: null } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
code: 'NO_SUCH_CLIP',
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
} },
{ label: '他人のクリップ', user: (): User => bob, assertion: {
code: 'NO_SUCH_CLIP',
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
} },
])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
endpoint: '/clips/delete',
parameters: {
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assertion,
}));
test('のID指定取得ができる', async () => {
const clip = await create();
const res = await show({ clipId: clip.id });
assert.deepStrictEqual(res, clip);
});
test('のID指定取得は他人のPrivateなクリップは取得できない', async () => {
const clip = await create({ isPublic: false }, { user: bob } );
failedApiCall({
endpoint: '/clips/show',
parameters: { clipId: clip.id },
user: alice,
}, {
status: 400,
code: 'NO_SUCH_CLIP',
id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20',
});
});
test.each([
{ label: 'clipId未指定', parameters: { clipId: undefined } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
code: 'NO_SUCH_CLIP',
id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20',
} },
])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({
endpoint: '/clips/show',
parameters: {
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assetion,
}));
test('の一覧(clips/list)が取得できる(空)', async () => {
const res = await list({});
assert.deepStrictEqual(res, []);
});
test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => {
const clipLimit = DEFAULT_POLICIES.clipLimit + 1;
const clips = await createMany({}, clipLimit);
const res = await list({
parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる
});
// 返ってくる配列には順序保障がないのでidでソートして厳密比較
assert.deepStrictEqual(
res.sort(compareBy(s => s.id)),
clips.sort(compareBy(s => s.id)),
);
});
test('の一覧が取得できる(空)', async () => {
const res = await usersClips({
parameters: {
userId: alice.id,
},
});
assert.deepStrictEqual(res, []);
});
test.each([
{ label: '' },
{ label: '他人アカウントから', user: (): User => bob },
])('の一覧が$label取得できる', async () => {
const clips = await createMany({ isPublic: true });
const res = await usersClips({
parameters: {
userId: alice.id,
},
});
// 返ってくる配列には順序保障がないのでidでソートして厳密比較
assert.deepStrictEqual(
res.sort(compareBy<Clip>(s => s.id)),
clips.sort(compareBy(s => s.id)));
// 認証状態で見たときだけisFavoritedが入っている
for (const clip of res) {
assert.strictEqual(clip.isFavorited, false);
}
});
test.each([
{ label: '未認証', user: (): undefined => undefined },
{ label: '存在しないユーザーのもの', parameters: { userId: 'xxxxxxx' } },
])('の一覧は$labelでも取得できる', async ({ parameters, user }) => {
const clips = await createMany({ isPublic: true });
const res = await usersClips({
parameters: {
userId: alice.id,
limit: clips.length,
...parameters,
},
user: (user ?? ((): User => alice))(),
});
// 未認証で見たときはisFavoritedは入らない
for (const clip of res) {
assert.strictEqual('isFavorited' in clip, false);
}
});
test('の一覧はPrivateなクリップを含まない(自分のものであっても。)', async () => {
await create({ isPublic: false });
const aliceClip = await create({ isPublic: true });
const res = await usersClips({
parameters: {
userId: alice.id,
limit: 2,
},
});
assert.deepStrictEqual(res, [aliceClip]);
});
test('の一覧はID指定で範囲選択ができる', async () => {
const clips = await createMany({ isPublic: true }, 7);
clips.sort(compareBy(s => s.id));
const res = await usersClips({
parameters: {
userId: alice.id,
sinceId: clips[1].id,
untilId: clips[5].id,
limit: 4,
},
});
// Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較
assert.deepStrictEqual(
res.sort(compareBy<Clip>(s => s.id)),
[clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない
clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id));
});
test.each([
{ label: 'userId未指定', parameters: { userId: undefined } },
{ label: 'limitゼロ', parameters: { limit: 0 } },
{ label: 'limit最大+1', parameters: { limit: 101 } },
])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({
endpoint: '/users/clips',
parameters: {
userId: alice.id,
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
}));
test.each([
{ label: '作成', endpoint: '/clips/create' },
{ label: '更新', endpoint: '/clips/update' },
{ label: '削除', endpoint: '/clips/delete' },
{ label: '取得', endpoint: '/clips/list' },
{ label: 'お気に入り設定', endpoint: '/clips/favorite' },
{ label: 'お気に入り解除', endpoint: '/clips/unfavorite' },
{ label: 'お気に入り取得', endpoint: '/clips/my-favorites' },
{ label: 'ノート追加', endpoint: '/clips/add-note' },
{ label: 'ノート削除', endpoint: '/clips/remove-note' },
])('の$labelは未認証ではできない', async ({ endpoint }) => await failedApiCall({
endpoint: endpoint,
parameters: {},
user: undefined,
}, {
status: 401,
code: 'CREDENTIAL_REQUIRED',
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
}));
describe('のお気に入り', () => {
let aliceClip: Clip;
type FavoriteParam = JTDDataType<typeof FavoriteParamDef>;
const favorite = async (parameters: FavoriteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
return successfulApiCall<void>({
endpoint: '/clips/favorite',
parameters,
user: alice,
...request,
}, {
status: 204,
});
};
type UnfavoriteParam = JTDDataType<typeof UnfavoriteParamDef>;
const unfavorite = async (parameters: UnfavoriteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
return successfulApiCall<void>({
endpoint: '/clips/unfavorite',
parameters,
user: alice,
...request,
}, {
status: 204,
});
};
const myFavorites = async (request: Partial<ApiRequest> = {}): Promise<Clip[]> => {
return successfulApiCall<Clip[]>({
endpoint: '/clips/my-favorites',
parameters: {},
user: alice,
...request,
});
};
beforeEach(async () => {
aliceClip = await create();
});
test('を設定できる。', async () => {
await favorite({ clipId: aliceClip.id });
const clip = await show({ clipId: aliceClip.id });
assert.strictEqual(clip.favoritedCount, 1);
assert.strictEqual(clip.isFavorited, true);
});
test('はPublicな他人のクリップに設定できる。', async () => {
const publicClip = await create({ isPublic: true });
await favorite({ clipId: publicClip.id }, { user: bob });
const clip = await show({ clipId: publicClip.id }, { user: bob });
assert.strictEqual(clip.favoritedCount, 1);
assert.strictEqual(clip.isFavorited, true);
// isFavoritedは見る人によって切り替わる。
const clip2 = await show({ clipId: publicClip.id });
assert.strictEqual(clip2.favoritedCount, 1);
assert.strictEqual(clip2.isFavorited, false);
});
test('は1つのクリップに対して複数人が設定できる。', async () => {
const publicClip = await create({ isPublic: true });
await favorite({ clipId: publicClip.id }, { user: bob });
await favorite({ clipId: publicClip.id });
const clip = await show({ clipId: publicClip.id }, { user: bob });
assert.strictEqual(clip.favoritedCount, 2);
assert.strictEqual(clip.isFavorited, true);
const clip2 = await show({ clipId: publicClip.id });
assert.strictEqual(clip2.favoritedCount, 2);
assert.strictEqual(clip2.isFavorited, true);
});
test('は11を超えて設定できる。', async () => {
const clips = [
aliceClip,
...await createMany({}, 10, alice),
...await createMany({ isPublic: true }, 10, bob),
];
for (const clip of clips) {
await favorite({ clipId: clip.id });
}
// pagenationはない。全部一気にとれる。
const favorited = await myFavorites();
assert.strictEqual(favorited.length, clips.length);
for (const clip of favorited) {
assert.strictEqual(clip.favoritedCount, 1);
assert.strictEqual(clip.isFavorited, true);
}
});
test('は同じクリップに対して二回設定できない。', async () => {
await favorite({ clipId: aliceClip.id });
await failedApiCall({
endpoint: '/clips/favorite',
parameters: {
clipId: aliceClip.id,
},
user: alice,
}, {
status: 400,
code: 'ALREADY_FAVORITED',
id: '92658936-c625-4273-8326-2d790129256e',
});
});
test.each([
{ label: 'clipIdがnull', parameters: { clipId: null } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
code: 'NO_SUCH_CLIP',
id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5',
} },
{ label: '他人のクリップ', user: (): User => bob, assertion: {
code: 'NO_SUCH_CLIP',
id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5',
} },
])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
endpoint: '/clips/favorite',
parameters: {
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assertion,
}));
test('を設定解除できる。', async () => {
await favorite({ clipId: aliceClip.id });
await unfavorite({ clipId: aliceClip.id });
const clip = await show({ clipId: aliceClip.id });
assert.strictEqual(clip.favoritedCount, 0);
assert.strictEqual(clip.isFavorited, false);
assert.deepStrictEqual(await myFavorites(), []);
});
test.each([
{ label: 'clipIdがnull', parameters: { clipId: null } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
code: 'NO_SUCH_CLIP',
id: '2603966e-b865-426c-94a7-af4a01241dc1',
} },
{ label: '他人のクリップ', user: (): User => bob, assertion: {
code: 'NOT_FAVORITED',
id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187',
} },
{ label: 'お気に入りしていないクリップ', assertion: {
code: 'NOT_FAVORITED',
id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187',
} },
])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({
endpoint: '/clips/unfavorite',
parameters: {
clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id,
...parameters,
},
user: alice,
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assertion,
}));
test('を取得できる。', async () => {
await favorite({ clipId: aliceClip.id });
const favorited = await myFavorites();
assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]);
});
test('を取得したとき他人のお気に入りは含まない。', async () => {
await favorite({ clipId: aliceClip.id });
const favorited = await myFavorites({ user: bob });
assert.deepStrictEqual(favorited, []);
});
});
describe('に紐づくノート', () => {
let aliceClip: Clip;
const sampleNotes = (): Note[] => [
aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote,
bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote,
];
type AddNoteParam = JTDDataType<typeof AddNoteParamDef>;
const addNote = async (parameters: AddNoteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
return successfulApiCall<void>({
endpoint: '/clips/add-note',
parameters,
user: alice,
...request,
}, {
status: 204,
});
};
type RemoveNoteParam = JTDDataType<typeof RemoveNoteParamDef>;
const removeNote = async (parameters: RemoveNoteParam, request: Partial<ApiRequest> = {}): Promise<void> => {
return successfulApiCall<void>({
endpoint: '/clips/remove-note',
parameters,
user: alice,
...request,
}, {
status: 204,
});
};
type NotesParam = JTDDataType<typeof NotesParamDef>;
const notes = async (parameters: Partial<NotesParam>, request: Partial<ApiRequest> = {}): Promise<Note[]> => {
return successfulApiCall<Note[]>({
endpoint: '/clips/notes',
parameters,
user: alice,
...request,
});
};
beforeEach(async () => {
aliceClip = await create();
});
test('を追加できる。', async () => {
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
const res = await show({ clipId: aliceClip.id });
assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString());
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]);
// 他人の非公開ノートも突っ込める
await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id });
await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id });
await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id });
});
test('として同じノートを二回紐づけることはできない', async () => {
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
await failedApiCall({
endpoint: '/clips/add-note',
parameters: {
clipId: aliceClip.id,
noteId: aliceNote.id,
},
user: alice,
}, {
status: 400,
code: 'ALREADY_CLIPPED',
id: '734806c4-542c-463a-9311-15c512803965',
});
});
// TODO: 17000msくらいかかる...
test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => {
const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1;
const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, {
text: `test ${i}`,
}) as unknown)) as Note[];
await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id })));
await failedApiCall({
endpoint: '/clips/add-note',
parameters: {
clipId: aliceClip.id,
noteId: aliceNote.id,
},
user: alice,
}, {
status: 400,
code: 'TOO_MANY_CLIP_NOTES',
id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118',
});
});
test('は他人のクリップへ追加できない。', async () => await failedApiCall({
endpoint: '/clips/add-note',
parameters: {
clipId: aliceClip.id,
noteId: aliceNote.id,
},
user: bob,
}, {
status: 400,
code: 'NO_SUCH_CLIP',
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
}));
test.each([
{ label: 'clipId未指定', parameters: { clipId: undefined } },
{ label: 'noteId未指定', parameters: { noteId: undefined } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
code: 'NO_SUCH_CLIP',
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
} },
{ label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: {
code: 'NO_SUCH_NOTE',
id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b',
} },
{ label: '他人のクリップ', user: (): object => bob, assetion: {
code: 'NO_SUCH_CLIP',
id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf',
} },
])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({
endpoint: '/clips/add-note',
parameters: {
clipId: aliceClip.id,
noteId: aliceNote.id,
...parameters,
},
user: (user ?? ((): User => alice))(),
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assetion,
}));
test('を削除できる。', async () => {
await addNote({ clipId: aliceClip.id, noteId: aliceNote.id });
await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id });
assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []);
});
test.each([
{ label: 'clipId未指定', parameters: { clipId: undefined } },
{ label: 'noteId未指定', parameters: { noteId: undefined } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: {
code: 'NO_SUCH_CLIP',
id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる
} },
{ label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: {
code: 'NO_SUCH_NOTE',
id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteと異なる
} },
{ label: '他人のクリップ', user: (): object => bob, assetion: {
code: 'NO_SUCH_CLIP',
id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる
} },
])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({
endpoint: '/clips/remove-note',
parameters: {
clipId: aliceClip.id,
noteId: aliceNote.id,
...parameters,
},
user: (user ?? ((): User => alice))(),
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assetion,
}));
test('を取得できる。', async () => {
const noteList = sampleNotes();
for (const note of noteList) {
await addNote({ clipId: aliceClip.id, noteId: note.id });
}
const res = await notes({ clipId: aliceClip.id });
// 自分のノートは非公開でも入れられるし、見える
// 他人の非公開ノートは入れられるけど、除外される
const expects = [
aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote,
bobNote, bobHomeNote,
];
assert.deepStrictEqual(
res.sort(compareBy(s => s.id)),
expects.sort(compareBy(s => s.id)));
});
test('を始端IDとlimitで取得できる。', async () => {
const noteList = sampleNotes();
noteList.sort(compareBy(s => s.id));
for (const note of noteList) {
await addNote({ clipId: aliceClip.id, noteId: note.id });
}
const res = await notes({
clipId: aliceClip.id,
sinceId: noteList[2].id,
limit: 3,
});
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
const expects = [noteList[3], noteList[4], noteList[5]];
assert.deepStrictEqual(
res.sort(compareBy(s => s.id)),
expects.sort(compareBy(s => s.id)));
});
test('をID範囲指定で取得できる。', async () => {
const noteList = sampleNotes();
noteList.sort(compareBy(s => s.id));
for (const note of noteList) {
await addNote({ clipId: aliceClip.id, noteId: note.id });
}
const res = await notes({
clipId: aliceClip.id,
sinceId: noteList[1].id,
untilId: noteList[4].id,
});
// Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較
const expects = [noteList[2], noteList[3]];
assert.deepStrictEqual(
res.sort(compareBy(s => s.id)),
expects.sort(compareBy(s => s.id)));
});
test.todo('Remoteのートもクリップできる。どうテストしよう');
test('は他人のPublicなクリップからも取得できる。', async () => {
const bobClip = await create({ isPublic: true }, { user: bob } );
await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob });
const res = await notes({ clipId: bobClip.id });
assert.deepStrictEqual(res, [aliceNote]);
});
test('はPublicなクリップなら認証なしでも取得できる。(非公開ートはhideされて返ってくる)', async () => {
const publicClip = await create({ isPublic: true });
await addNote({ clipId: publicClip.id, noteId: aliceNote.id });
await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id });
await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id });
await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id });
const res = await notes({ clipId: publicClip.id }, { user: undefined });
const expects = [
aliceNote, aliceHomeNote,
// 認証なしだと非公開ートは結果には含むけどhideされる。
hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote),
];
assert.deepStrictEqual(
res.sort(compareBy(s => s.id)),
expects.sort(compareBy(s => s.id)));
});
test.todo('ブロック、ミュートされたユーザーからの設定取得etc.');
test.each([
{ label: 'clipId未指定', parameters: { clipId: undefined } },
{ label: 'limitゼロ', parameters: { limit: 0 } },
{ label: 'limit最大+1', parameters: { limit: 101 } },
{ label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: {
code: 'NO_SUCH_CLIP',
id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00',
} },
{ label: '他人のPrivateなクリップから', user: (): object => bob, assertion: {
code: 'NO_SUCH_CLIP',
id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00',
} },
{ label: '未認証でPrivateなクリップから', user: (): undefined => undefined, assertion: {
code: 'NO_SUCH_CLIP',
id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00',
} },
])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({
endpoint: '/clips/notes',
parameters: {
clipId: aliceClip.id,
...parameters,
},
user: (user ?? ((): User => alice))(),
}, {
status: 400,
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
...assertion,
}));
});
});

View file

@ -162,14 +162,14 @@ describe('Endpoints', () => {
const res = await api('/users/show', { const res = await api('/users/show', {
userId: '000000000000000000000000', userId: '000000000000000000000000',
}); });
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 404);
}); });
test('間違ったIDで怒られる', async () => { test('間違ったIDで怒られる', async () => {
const res = await api('/users/show', { const res = await api('/users/show', {
userId: 'kyoppie', userId: 'kyoppie',
}); });
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 404);
}); });
}); });
@ -841,4 +841,12 @@ describe('Endpoints', () => {
assert.strictEqual(res.body[0].id, carolPost.id); assert.strictEqual(res.body[0].id, carolPost.id);
}); });
}); });
describe('URL preview', () => {
test('Error from summaly becomes HTTP 422', async () => {
const res = await simpleGet('/url?url=https://e:xample.com');
assert.strictEqual(res.status, 422);
assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED');
});
});
}); });

View file

@ -1,55 +1,56 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3';
import { mockClient } from 'aws-sdk-client-mock';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { DriveService } from '@/core/DriveService.js'; import { DriveService } from '@/core/DriveService.js';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { S3Service } from '@/core/S3Service';
import type { Meta } from '@/models';
import type { DeleteObjectOutput } from 'aws-sdk/clients/s3';
import type { AWSError } from 'aws-sdk/lib/error';
import type { PromiseResult, Request } from 'aws-sdk/lib/request';
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
describe('DriveService', () => { describe('DriveService', () => {
let app: TestingModule; let app: TestingModule;
let driveService: DriveService; let driveService: DriveService;
const s3Mock = mockClient(S3Client);
beforeEach(async () => { beforeAll(async () => {
app = await Test.createTestingModule({ app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule], imports: [GlobalModule, CoreModule],
providers: [DriveService, S3Service], providers: [DriveService],
}).compile(); }).compile();
app.enableShutdownHooks(); app.enableShutdownHooks();
driveService = app.get<DriveService>(DriveService); driveService = app.get<DriveService>(DriveService);
const s3Service = app.get<S3Service>(S3Service);
const s3 = s3Service.getS3({} as Meta);
// new S3() surprisingly does not return an instance of class S3.
// Let's use getPrototypeOf here to get a real prototype, since spying on S3.prototype doesn't work.
// TODO: Use `aws-sdk-client-mock` package when upgrading to AWS SDK v3.
jest.spyOn(Object.getPrototypeOf(s3), 'deleteObject').mockImplementation(() => {
// Roughly mock AWS request object
return {
async promise(): Promise<PromiseResult<DeleteObjectOutput, AWSError>> {
const err = new Error('mock') as AWSError;
err.code = 'NoSuchKey';
throw err;
},
} as Request<DeleteObjectOutput, AWSError>;
}); });
beforeEach(async () => {
s3Mock.reset();
});
afterAll(async () => {
await app.close();
}); });
describe('Object storage', () => { describe('Object storage', () => {
test('delete a file', async () => {
s3Mock.on(DeleteObjectCommand)
.resolves({} as DeleteObjectCommandOutput);
await driveService.deleteObjectStorageFile('peace of the world');
});
test('delete a file then unexpected error', async () => {
s3Mock.on(DeleteObjectCommand)
.rejects(new InvalidObjectState({ $metadata: {}, message: '' }));
await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error);
});
test('delete a file with no valid key', async () => { test('delete a file with no valid key', async () => {
try { // Some S3 implementations returns 404 Not Found on deleting with a non-existent key
s3Mock.on(DeleteObjectCommand)
.rejects(new NoSuchKey({ $metadata: {}, message: 'allowed error.' }));
await driveService.deleteObjectStorageFile('lol no way'); await driveService.deleteObjectStorageFile('lol no way');
} catch (err: any) {
console.log(err.cause);
throw err;
}
}); });
}); });
}); });

View file

@ -74,19 +74,19 @@ describe('ReactionService', () => {
}); });
test('fallback - undefined', async () => { test('fallback - undefined', async () => {
assert.strictEqual(await reactionService.toDbReaction(undefined), '👍'); assert.strictEqual(await reactionService.toDbReaction(undefined), '');
}); });
test('fallback - null', async () => { test('fallback - null', async () => {
assert.strictEqual(await reactionService.toDbReaction(null), '👍'); assert.strictEqual(await reactionService.toDbReaction(null), '');
}); });
test('fallback - empty', async () => { test('fallback - empty', async () => {
assert.strictEqual(await reactionService.toDbReaction(''), '👍'); assert.strictEqual(await reactionService.toDbReaction(''), '');
}); });
test('fallback - unknown', async () => { test('fallback - unknown', async () => {
assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍'); assert.strictEqual(await reactionService.toDbReaction('unknown'), '');
}); });
}); });
}); });

View file

@ -0,0 +1,77 @@
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { mockClient } from 'aws-sdk-client-mock';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { S3Service } from '@/core/S3Service';
import { Meta } from '@/models';
import type { TestingModule } from '@nestjs/testing';
describe('S3Service', () => {
let app: TestingModule;
let s3Service: S3Service;
const s3Mock = mockClient(S3Client);
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
providers: [S3Service],
}).compile();
app.enableShutdownHooks();
s3Service = app.get<S3Service>(S3Service);
});
beforeEach(async () => {
s3Mock.reset();
});
afterAll(async () => {
await app.close();
});
describe('upload', () => {
test('upload a file', async () => {
s3Mock.on(PutObjectCommand).resolves({});
await s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x',
});
});
test('upload a large file', async () => {
s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: '1' });
s3Mock.on(UploadPartCommand).resolves({ ETag: '1' });
s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' });
await s3Service.upload({} as Meta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ
});
});
test('upload a file error', async () => {
s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' });
await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x',
})).rejects.toThrowError(Error);
});
test('upload a large file error', async () => {
s3Mock.on(UploadPartCommand).rejects();
await expect(s3Service.upload({} as Meta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ
})).rejects.toThrowError(Error);
});
});
});

View file

@ -1,5 +1,7 @@
import * as assert from 'node:assert';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { isAbsolute, basename } from 'node:path'; import { isAbsolute, basename } from 'node:path';
import { inspect } from 'node:util';
import WebSocket from 'ws'; import WebSocket from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch'; import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@ -22,6 +24,36 @@ export const api = async (endpoint: string, params: any, me?: any) => {
return await request(`api/${normalized}`, params, me); return await request(`api/${normalized}`, params, me);
}; };
export type ApiRequest = {
endpoint: string,
parameters: object,
user: object | undefined,
};
export const successfulApiCall = async <T, >(request: ApiRequest, assertion: {
status: number,
} = { status: 200 }): Promise<T> => {
const { endpoint, parameters, user } = request;
const { status } = assertion;
const res = await api(endpoint, parameters, user);
assert.strictEqual(res.status, status, inspect(res.body));
return res.body;
};
export const failedApiCall = async <T, >(request: ApiRequest, assertion: {
status: number,
code: string,
id: string
}): Promise<T> => {
const { endpoint, parameters, user } = request;
const { status, code, id } = assertion;
const res = await api(endpoint, parameters, user);
assert.strictEqual(res.status, status, inspect(res.body));
assert.strictEqual(res.body.error.code, code, inspect(res.body));
assert.strictEqual(res.body.error.id, id, inspect(res.body));
return res.body;
};
const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
const auth = me ? { const auth = me ? {
i: me.token, i: me.token,
@ -69,6 +101,21 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create']
return res.body ? res.body.createdNote : null; return res.body ? res.body.createdNote : null;
}; };
// 非公開ートをAPI越しに見たときのート NoteEntityService.ts
export const hiddenNote = (note: any): any => {
const temp = {
...note,
fileIds: [],
files: [],
text: null,
cw: null,
isHidden: true,
};
delete temp.visibleUserIds;
delete temp.poll;
return temp;
};
export const react = async (user: any, note: any, reaction: string): Promise<any> => { export const react = async (user: any, note: any, reaction: string): Promise<any> => {
await api('notes/reactions/create', { await api('notes/reactions/create', {
noteId: note.id, noteId: note.id,

View file

@ -0,0 +1,6 @@
import * as misskey from 'misskey-js';
import { Cache } from '@/scripts/cache';
export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity);
export const rolesCache = new Cache(Infinity);
export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity);

View file

@ -32,14 +32,14 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineAsyncComponent, ref } from 'vue'; import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import bytes from '@/filters/bytes'; import bytes from '@/filters/bytes';
import * as os from '@/os'; import * as os from '@/os';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { $i } from '@/account'; import { $i } from '@/account';
import { getDriveFileMenu } from '@/scripts/get-drive-file-menu';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
@ -60,48 +60,16 @@ const isDragging = ref(false);
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
function getMenu() {
return [{
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: rename,
}, {
text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
action: toggleSensitive,
}, {
text: i18n.ts.describeFile,
icon: 'ti ti-text-caption',
action: describe,
}, null, {
text: i18n.ts.copyUrl,
icon: 'ti ti-link',
action: copyUrl,
}, {
type: 'a',
href: props.file.url,
target: '_blank',
text: i18n.ts.download,
icon: 'ti ti-download',
download: props.file.name,
}, null, {
text: i18n.ts.delete,
icon: 'ti ti-trash',
danger: true,
action: deleteFile,
}];
}
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
if (props.selectMode) { if (props.selectMode) {
emit('chosen', props.file); emit('chosen', props.file);
} else { } else {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
} }
} }
function onContextmenu(ev: MouseEvent) { function onContextmenu(ev: MouseEvent) {
os.contextMenu(getMenu(), ev); os.contextMenu(getDriveFileMenu(props.file), ev);
} }
function onDragstart(ev: DragEvent) { function onDragstart(ev: DragEvent) {
@ -118,62 +86,6 @@ function onDragend() {
isDragging.value = false; isDragging.value = false;
emit('dragend'); emit('dragend');
} }
function rename() {
os.inputText({
title: i18n.ts.renameFile,
placeholder: i18n.ts.inputNewFileName,
default: props.file.name,
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/files/update', {
fileId: props.file.id,
name: name,
});
});
}
function describe() {
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: props.file.comment != null ? props.file.comment : '',
file: props.file,
}, {
done: caption => {
os.api('drive/files/update', {
fileId: props.file.id,
comment: caption.length === 0 ? null : caption,
});
},
}, 'closed');
}
function toggleSensitive() {
os.api('drive/files/update', {
fileId: props.file.id,
isSensitive: !props.file.isSensitive,
});
}
function copyUrl() {
copyToClipboard(props.file.url);
os.success();
}
/*
function addApp() {
alert('not implemented yet');
}
*/
async function deleteFile() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }),
});
if (canceled) return;
os.api('drive/files/delete', {
fileId: props.file.id,
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -109,6 +109,9 @@
<button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)"> <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i> <i class="ti ti-minus"></i>
</button> </button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()"> <button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="menu()">
<i class="ti ti-dots"></i> <i class="ti ti-dots"></i>
</button> </button>
@ -151,7 +154,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu'; import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
@ -192,6 +195,7 @@ const menuButton = shallowRef<HTMLElement>();
const renoteButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
@ -392,6 +396,10 @@ function menu(viaKeyboard = false): void {
}).then(focus); }).then(focus);
} }
async function clip() {
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClipPage }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void { function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return; if (!isMyRenote) return;
os.popupMenu([{ os.popupMenu([{

View file

@ -114,6 +114,9 @@
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)"> <button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ti ti-minus"></i> <i class="ti ti-minus"></i>
</button> </button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="button _button" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="button _button" @mousedown="menu()"> <button ref="menuButton" class="button _button" @mousedown="menu()">
<i class="ti ti-dots"></i> <i class="ti ti-dots"></i>
</button> </button>
@ -156,7 +159,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account'; import { $i } from '@/account';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu'; import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture'; import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
@ -196,6 +199,7 @@ const menuButton = shallowRef<HTMLElement>();
const renoteButton = shallowRef<HTMLElement>(); const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>(); const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>(); const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId); const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false); const showContent = ref(false);
@ -384,6 +388,10 @@ function menu(viaKeyboard = false): void {
}).then(focus); }).then(focus);
} }
async function clip() {
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void { function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return; if (!isMyRenote) return;
os.popupMenu([{ os.popupMenu([{

View file

@ -1,25 +1,26 @@
<template> <template>
<span v-if="!link" v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick"> <component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
<img :class="$style.inner" :src="url" decoding="async"/> <img :class="$style.inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<template v-if="user.isCat"> <div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
<div :class="$style.earLeft"/> <div :class="$style.earLeft">
<div :class="$style.earRight"/> <div v-if="useBlurEffect" :class="$style.layer">
</template> <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
</span> </div>
<MkA v-else v-user-preview="preview ? user.id : undefined" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" :to="userPage(user)" :target="target"> </div>
<img :class="$style.inner" :src="url" decoding="async"/> <div :class="$style.earRight">
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/> <div v-if="useBlurEffect" :class="$style.layer">
<template v-if="user.isCat"> <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.earLeft"/> </div>
<div :class="$style.earRight"/> </div>
</template> </div>
</MkA> </component>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch } from 'vue'; import { watch } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import MkA from './MkA.vue';
import { getStaticImageUrl } from '@/scripts/media-proxy'; import { getStaticImageUrl } from '@/scripts/media-proxy';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
import { acct, userPage } from '@/filters/user'; import { acct, userPage } from '@/filters/user';
@ -27,6 +28,7 @@ import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
const squareAvatars = $ref(defaultStore.state.squareAvatars); const squareAvatars = $ref(defaultStore.state.squareAvatars);
const useBlurEffect = $ref(defaultStore.state.useBlurEffect);
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
user: misskey.entities.User; user: misskey.entities.User;
@ -45,15 +47,20 @@ const emit = defineEmits<{
(ev: 'click', v: MouseEvent): void; (ev: 'click', v: MouseEvent): void;
}>(); }>();
const bound = $computed(() => props.link
? { to: userPage(props.user), target: props.target }
: {});
const url = $computed(() => defaultStore.state.disableShowingAnimatedImages const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.user.avatarUrl) ? getStaticImageUrl(props.user.avatarUrl)
: props.user.avatarUrl); : props.user.avatarUrl);
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent): void {
if (props.link) return;
emit('click', ev); emit('click', ev);
} }
let color = $ref(); let color = $ref<string | undefined>();
watch(() => props.user.avatarBlurhash, () => { watch(() => props.user.avatarBlurhash, () => {
color = extractAvgColorFromBlurhash(props.user.avatarBlurhash); color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
@ -120,6 +127,25 @@ watch(() => props.user.avatarBlurhash, () => {
} }
.cat { .cat {
> .ears {
contain: strict;
position: absolute;
top: -50%;
left: -50%;
width: 100%;
height: 100%;
padding: 50%;
&.mask {
-webkit-mask:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') center / 50% 50%,
linear-gradient(#fff, #fff);
-webkit-mask-composite: destination-out, source-over;
mask:
url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><filter id="a"><feGaussianBlur in="SourceGraphic" stdDeviation="1"/></filter><circle cx="16" cy="16" r="15" filter="url(%23a)"/></svg>') exclude center / 50% 50%,
linear-gradient(#fff, #fff); // polyfill of `image(#fff)`
}
> .earLeft, > .earLeft,
> .earRight { > .earRight {
contain: strict; contain: strict;
@ -128,7 +154,7 @@ watch(() => props.user.avatarBlurhash, () => {
width: 50%; width: 50%;
background: currentColor; background: currentColor;
&::before { &::after {
contain: strict; contain: strict;
content: ''; content: '';
display: block; display: block;
@ -137,19 +163,70 @@ watch(() => props.user.avatarBlurhash, () => {
margin: 20%; margin: 20%;
background: #df548f; background: #df548f;
} }
> .layer {
contain: strict;
position: absolute;
top: 0;
width: 280%;
height: 280%;
> .plot {
contain: strict;
width: 100%;
height: 100%;
clip-path: path('M0 0H1V1H0z');
transform: scale(32767);
transform-origin: 0 0;
}
}
} }
> .earLeft { > .earLeft {
border-radius: 0 75% 75%;
transform: rotate(37.5deg) skew(30deg); transform: rotate(37.5deg) skew(30deg);
&, &::after {
border-radius: 0 75% 75%;
}
> .layer {
left: 0;
transform:
skew(-30deg)
rotate(-37.5deg)
translate(-2.82842712475%, /* -2 * sqrt(2) */
-38.5857864376%); /* 40 - 2 * sqrt(2) */
> .plot {
background-position: 20% 10%; /* ~= 37.5deg */
}
}
} }
> .earRight { > .earRight {
border-radius: 75% 0 75% 75%;
transform: rotate(-37.5deg) skew(-30deg); transform: rotate(-37.5deg) skew(-30deg);
&, &::after {
border-radius: 75% 0 75% 75%;
}
> .layer {
right: 0;
transform:
skew(30deg)
rotate(37.5deg)
translate(2.82842712475%, /* 2 * sqrt(2) */
-38.5857864376%); /* 40 - 2 * sqrt(2) */
> .plot {
background-position: 80% 10%; /* ~= 37.5deg */
}
}
}
} }
&:hover { &:hover {
> .ears {
> .earLeft { > .earLeft {
animation: earwiggleleft 1s infinite; animation: earwiggleleft 1s infinite;
} }
@ -159,4 +236,5 @@ watch(() => props.user.avatarBlurhash, () => {
} }
} }
} }
}
</style> </style>

View file

@ -10,6 +10,8 @@
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option> <option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option> <option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option> <option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
<option value="and">{{ i18n.ts._role._condition.and }}</option> <option value="and">{{ i18n.ts._role._condition.and }}</option>
<option value="or">{{ i18n.ts._role._condition.or }}</option> <option value="or">{{ i18n.ts._role._condition.or }}</option>
<option value="not">{{ i18n.ts._role._condition.not }}</option> <option value="not">{{ i18n.ts._role._condition.not }}</option>
@ -42,7 +44,7 @@
<template #suffix>sec</template> <template #suffix>sec</template>
</MkInput> </MkInput>
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
</MkInput> </MkInput>
</div> </div>
</template> </template>
@ -91,6 +93,8 @@ const type = computed({
if (t === 'followersMoreThanOrEq') v.value.value = 10; if (t === 'followersMoreThanOrEq') v.value.value = 10;
if (t === 'followingLessThanOrEq') v.value.value = 10; if (t === 'followingLessThanOrEq') v.value.value = 10;
if (t === 'followingMoreThanOrEq') v.value.value = 10; if (t === 'followingMoreThanOrEq') v.value.value = 10;
if (t === 'notesLessThanOrEq') v.value.value = 10;
if (t === 'notesMoreThanOrEq') v.value.value = 10;
v.value.type = t; v.value.type = t;
}, },
}); });

View file

@ -12,7 +12,7 @@
<MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
</div> </div>
</MkSpacer> </MkSpacer>
</div> </div>
@ -220,6 +220,12 @@ onUnmounted(() => {
ro.disconnect(); ro.disconnect();
}); });
watch(router.currentRef, (to) => {
if (to.route.path === "/admin" && to.child?.route.name == null && !narrow) {
router.replace('/admin/overview');
}
});
provideMetadataReceiver((info) => { provideMetadataReceiver((info) => {
if (info == null) { if (info == null) {
childInfo = null; childInfo = null;

View file

@ -46,7 +46,7 @@ let sensitiveWords: string = $ref('');
async function init() { async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
sensitiveWords = meta.pinnedUsers.join('\n'); sensitiveWords = meta.sensitiveWords.join('\n');
} }
function save() { function save() {

View file

@ -4,6 +4,8 @@
<MkSpacer :content-max="800"> <MkSpacer :content-max="800">
<XQueue v-if="tab === 'deliver'" domain="deliver"/> <XQueue v-if="tab === 'deliver'" domain="deliver"/>
<XQueue v-else-if="tab === 'inbox'" domain="inbox"/> <XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
<br>
<MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -15,6 +17,7 @@ import * as os from '@/os';
import * as config from '@/config'; import * as config from '@/config';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue';
let tab = $ref('deliver'); let tab = $ref('deliver');
@ -30,6 +33,18 @@ function clear() {
}); });
} }
function promoteAllQueues() {
os.confirm({
type: 'warning',
title: i18n.ts.retryAllQueuesConfirmTitle,
text: i18n.ts.retryAllQueuesConfirmText,
}).then(({ canceled }) => {
if (canceled) return;
os.apiWithDialog('admin/queue/promote', { type: tab });
});
}
const headerActions = $computed(() => [{ const headerActions = $computed(() => [{
asFullButton: true, asFullButton: true,
icon: 'ti ti-external-link', icon: 'ti ti-external-link',

View file

@ -26,6 +26,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { rolesCache } from '@/cache';
const router = useRouter(); const router = useRouter();
@ -61,6 +62,7 @@ if (props.id) {
} }
async function save() { async function save() {
rolesCache.delete();
if (role) { if (role) {
os.apiWithDialog('admin/roles/update', { os.apiWithDialog('admin/roles/update', {
roleId: role.id, roleId: role.id,

Some files were not shown because too many files have changed in this diff Show more