diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 07d9af12f7..90eb268dda 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -75,7 +75,7 @@ jobs: - run: corepack enable - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.0 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 405ee7c10a..4631615bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,32 @@ +## 2024.10.1 +### Note +- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、 +7日間活動していない場合は自動的に招待制へと移行(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。 +詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。 + +### General +- Feat: ユーザーの名前に禁止ワードを設定できるように + +### Client +- Enhance: タイムライン表示時のパフォーマンスを向上 +- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように +- Enhance: l10nの更新 +- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正 + +### Server +- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 ) +- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように +- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正 +- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正 + +### Server +- Fix: キューのエラーログを簡略化するように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649) + ## 2024.10.0 ### Note -- サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません) +- セキュリティ向上のため、サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません) - ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。 - なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能です(UI上で初期パスワードの入力欄を空欄にすると続行できます)。 - ユーザーデータを読み込む際の型が一部変更されました。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5a1f53ee1..6722bd7889 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -578,18 +578,18 @@ ESMではディレクトリインポートは廃止されているのと、デ ### Lighten CSS vars ``` css -color: hsl(from var(--accent) h s calc(l + 10)); +color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); ``` ### Darken CSS vars ``` css -color: hsl(from var(--accent) h s calc(l - 10)); +color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); ``` ### Add alpha to CSS vars ``` css -color: color(from var(--accent) srgb r g b / 0.5); +color: color(from var(--MI_THEME-accent) srgb r g b / 0.5); ``` diff --git a/idea/MkDisableSection.vue b/idea/MkDisableSection.vue index d177886569..360705071b 100644 --- a/idea/MkDisableSection.vue +++ b/idea/MkDisableSection.vue @@ -34,7 +34,7 @@ defineProps<{ width: 100%; height: 100%; cursor: not-allowed; - --color: color(from var(--error) srgb r g b / 0.25); + --color: color(from var(--MI_THEME-error) srgb r g b / 0.25); background-size: auto auto; background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px); } diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index d95600cb1f..de24ad4bb9 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1252,7 +1252,6 @@ _theme: buttonBg: "خلفية الأزرار" buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)" inputBorder: "حواف حقل الإدخال" - listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)" driveFolderBg: "خلفية مجلد قرص التخزين" messageBg: "خلفية المحادثة" _sfx: diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index ab0ee74bb4..0e761b0743 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -1017,7 +1017,6 @@ _theme: buttonBg: "বাটনের পটভূমি" buttonHoverBg: "বাটনের পটভূমি (হভার)" inputBorder: "ইনপুট ফিল্ডের বর্ডার" - listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" wallpaperOverlay: "ওয়ালপেপার ওভারলে" badge: "ব্যাজ" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index ad5fde37bc..b9f3fecc76 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -453,6 +453,7 @@ totpDescription: "Escriu una contrasenya d'un sol us fent servir l'aplicació d' moderator: "Moderador/a" moderation: "Moderació" moderationNote: "Nota de moderació " +moderationNoteDescription: "Pots escriure notes que es compartiran entre els moderadors." addModerationNote: "Afegir una nota de moderació " moderationLogs: "Registre de moderació " nUsersMentioned: "{n} usuaris mencionats" @@ -1284,6 +1285,15 @@ unknownWebAuthnKey: "Passkey desconeguda" passkeyVerificationFailed: "La verificació a fallat" passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya." messageToFollower: "Missatge als meus seguidors" +target: "Assumpte " +testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. No l'utilitzes en l'entorn real." +_abuseUserReport: + forward: "Reenviar " + forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima." + resolve: "Solució " + accept: "Acceptar " + reject: "Rebutjar" + resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament." _delivery: status: "Estat d'entrega " stop: "Suspés" @@ -1421,6 +1431,7 @@ _serverSettings: reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat." inquiryUrl: "URL de consulta " inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa." _accountMigration: moveFrom: "Migrar un altre compte a aquest" moveFromSub: "Crear un àlies per un altre compte" @@ -1974,7 +1985,6 @@ _theme: buttonBg: "Fons botó " buttonHoverBg: "Fons botó (en passar-hi per sobre)" inputBorder: "Contorn del cap d'introducció " - listItemHoverBg: "Fons dels elements d'una llista" driveFolderBg: "Fons de la carpeta Disc" wallpaperOverlay: "Superposició del fons de pantalla " badge: "Insígnia " @@ -2520,6 +2530,8 @@ _moderationLogTypes: markSensitiveDriveFile: "Fitxer marcat com a sensible" unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer" resolveAbuseReport: "Informe resolt" + forwardAbuseReport: "Informe reenviat" + updateAbuseReportNote: "Nota de moderació d'un informe actualitzat" createInvitation: "Crear codi d'invitació " createAd: "Anunci creat" deleteAd: "Anunci esborrat" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 4233a68f17..caf6d6e163 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1629,7 +1629,6 @@ _theme: buttonBg: "Pozadí tlačítka" buttonHoverBg: "Pozadí tlačítka (Hover)" inputBorder: "Ohraničení vstupního pole" - listItemHoverBg: "Pozadí položky seznamu (Hover)" driveFolderBg: "Pozadí složky disku" wallpaperOverlay: "Překrytí tapety" badge: "Odznak" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 35a04b453c..4e2bd06934 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1784,7 +1784,6 @@ _theme: buttonBg: "Hintergrund von Schaltflächen" buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" inputBorder: "Rahmen von Eingabefeldern" - listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)" driveFolderBg: "Hintergrund von Drive-Ordnern" wallpaperOverlay: "Hintergrundbild-Overlay" badge: "Wappen" diff --git a/locales/en-US.yml b/locales/en-US.yml index 126f769644..6ea7fb4f8d 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1984,7 +1984,6 @@ _theme: buttonBg: "Button background" buttonHoverBg: "Button background (Hover)" inputBorder: "Input field border" - listItemHoverBg: "List item background (Hover)" driveFolderBg: "Drive folder background" wallpaperOverlay: "Wallpaper overlay" badge: "Badge" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index de9ea0c32a..d574999e40 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -1915,7 +1915,6 @@ _theme: buttonBg: "Fondo de botón" buttonHoverBg: "Fondo de botón (hover)" inputBorder: "Borde de los campos de entrada" - listItemHoverBg: "Fondo de elemento de listas (hover)" driveFolderBg: "Fondo de capeta del drive" wallpaperOverlay: "Transparencia del fondo de pantalla" badge: "Medalla" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 7dfc64d63f..a7060c06fc 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1701,7 +1701,6 @@ _theme: buttonBg: "Arrière-plan du bouton" buttonHoverBg: "Arrière-plan du bouton (survolé)" inputBorder: "Cadre de la zone de texte" - listItemHoverBg: "Arrière-plan d'item de liste (survolé)" driveFolderBg: "Arrière-plan du dossier de disque" wallpaperOverlay: "Superposition de fond d'écran" badge: "Badge" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index fbfedb89e3..ce3958b167 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1924,7 +1924,6 @@ _theme: buttonBg: "Latar belakang tombol" buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" - listItemHoverBg: "Latar belakang daftar item (Mengambang)" driveFolderBg: "Latar belakang folder drive" wallpaperOverlay: "Lapisan wallpaper" badge: "Lencana" diff --git a/locales/index.d.ts b/locales/index.d.ts index d502c5b432..b5af5909a3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4366,6 +4366,10 @@ export interface Locale extends ILocale { * リモートサーバーのチャートを生成 */ "enableChartsForFederatedInstances": string; + /** + * リモートサーバーの情報を取得 + */ + "enableStatsForFederatedInstances": string; /** * ノートのアクションにクリップを追加 */ @@ -5166,6 +5170,26 @@ export interface Locale extends ILocale { * 対象 */ "target": string; + /** + * CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。 + */ + "testCaptchaWarning": string; + /** + * 禁止ワード(ユーザーの名前) + */ + "prohibitedWordsForNameOfUser": string; + /** + * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。 + */ + "prohibitedWordsForNameOfUserDescription": string; + /** + * 変更しようとした名前に禁止された文字列が含まれています + */ + "yourNameContainsProhibitedWords": string; + /** + * 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。 + */ + "yourNameContainsProhibitedWordsDescription": string; "_abuseUserReport": { /** * 転送 @@ -5696,6 +5720,10 @@ export interface Locale extends ILocale { * サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。 */ "inquiryUrlDescription": string; + /** + * 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。 + */ + "thisSettingWillAutomaticallyOffWhenModeratorsInactive": string; }; "_accountMigration": { /** @@ -7705,10 +7733,6 @@ export interface Locale extends ILocale { * 入力ボックスの縁取り */ "inputBorder": string; - /** - * リスト項目の背景 (ホバー) - */ - "listItemHoverBg": string; /** * ドライブフォルダーの背景 */ @@ -9637,6 +9661,14 @@ export interface Locale extends ILocale { * ユーザーが作成されたとき */ "userCreated": string; + /** + * モデレーターが一定期間非アクティブになったとき + */ + "inactiveModeratorsWarning": string; + /** + * モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき + */ + "inactiveModeratorsInvitationOnlyChanged": string; }; /** * Webhookを削除しますか? diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 004bb6e9fd..bcabf1bdb6 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -454,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d moderator: "Moderatore" moderation: "moderazione" moderationNote: "Promemoria di moderazione" +moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori." addModerationNote: "Aggiungi promemoria di moderazione" moderationLogs: "Cronologia di moderazione" nUsersMentioned: "{n} profili ne parlano" @@ -841,7 +842,7 @@ onlineStatus: "Stato di connessione" hideOnlineStatus: "Modalità invisibile" hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca." online: "Online" -active: "Attività" +active: "Attivo" offline: "Offline" notRecommended: "Sconsigliato" botProtection: "Protezione contro i bot" @@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." enableChartsForRemoteUser: "Abilita i grafici per i profili remoti" enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" +enableStatsForFederatedInstances: "Informazioni statistiche sui server federati" showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" reactionsDisplaySize: "Grandezza delle reazioni" limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale" @@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "Questa è una passkey sconosciuta." passkeyVerificationFailed: "La verifica della passkey non è riuscita." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato." messageToFollower: "Messaggio ai follower" +target: "Riferimento" +testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. Da non utilizzare in ambiente di produzione." +prohibitedWordsForNameOfUser: "Parole proibite (nome utente)" +prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione." +yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate" +yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione." +_abuseUserReport: + forward: "Inoltra" + forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo." + resolve: "Risolvi" + accept: "Approva" + reject: "Rifiuta" + resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente." _delivery: status: "Stato della consegna" stop: "Sospensione" @@ -1312,16 +1327,16 @@ _bubbleGame: _announcement: forExistingUsers: "Solo ai profili attuali" forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." - needConfirmationToRead: "Richiede la conferma di lettura" - needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce." + needConfirmationToRead: "Conferma di lettura obbligatoria" + needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"." end: "Archivia l'annuncio" tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi." readConfirmTitle: "Segnare come già letto?" readConfirmText: "Hai già letto \"{title}˝?" shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte." dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte." - silence: "Silenziare gli annunci" - silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette." + silence: "Annuncio silenzioso" + silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta." _initialAccountSetting: accountCreated: "Il tuo profilo è stato creato!" letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo." @@ -1422,6 +1437,7 @@ _serverSettings: reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis." inquiryUrl: "URL di contatto" inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo." _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" moveFromSub: "Crea un alias verso un altro profilo remoto" @@ -1975,7 +1991,6 @@ _theme: buttonBg: "Sfondo del pulsante" buttonHoverBg: "Sfondo del pulsante (sorvolato)" inputBorder: "Inquadra casella di testo" - listItemHoverBg: "Sfondo della voce di elenco (sorvolato)" driveFolderBg: "Sfondo della cartella di disco" wallpaperOverlay: "Sovrapposizione dello sfondo" badge: "Distintivo" @@ -2188,7 +2203,7 @@ _widgets: _userList: chooseList: "Seleziona una lista" clicker: "Cliccaggio" - birthdayFollowings: "Chi nacque oggi" + birthdayFollowings: "Compleanni del giorno" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2477,6 +2492,8 @@ _webhookSettings: abuseReport: "Quando arriva una segnalazione" abuseReportResolved: "Quando una segnalazione è risolta" userCreated: "Quando viene creato un profilo" + inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo" + inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\"" deleteConfirm: "Vuoi davvero eliminare il Webhook?" testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi." _abuseReport: @@ -2522,6 +2539,8 @@ _moderationLogTypes: markSensitiveDriveFile: "File nel Drive segnato come esplicito" unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito" resolveAbuseReport: "Segnalazione risolta" + forwardAbuseReport: "Segnalazione inoltrata" + updateAbuseReportNote: "Ha aggiornato la segnalazione" createInvitation: "Genera codice di invito" createAd: "Banner creato" deleteAd: "Banner eliminato" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 678bc7e66b..c448d4d50a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" enableChartsForRemoteUser: "リモートユーザーのチャートを生成" enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" +enableStatsForFederatedInstances: "リモートサーバーの情報を取得" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" reactionsDisplaySize: "リアクションの表示サイズ" limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する" @@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" messageToFollower: "フォロワーへのメッセージ" target: "対象" +testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" +prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)" +prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。" +yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています" +yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。" _abuseUserReport: forward: "転送" @@ -1440,6 +1446,7 @@ _serverSettings: reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" @@ -2018,7 +2025,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - listItemHoverBg: "リスト項目の背景 (ホバー)" driveFolderBg: "ドライブフォルダーの背景" wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" @@ -2553,6 +2559,8 @@ _webhookSettings: abuseReport: "ユーザーから通報があったとき" abuseReportResolved: "ユーザーからの通報を処理したとき" userCreated: "ユーザーが作成されたとき" + inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき" + inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき" deleteConfirm: "Webhookを削除しますか?" testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 52a8f41380..0a8b3828f2 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1943,7 +1943,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - listItemHoverBg: "リスト項目の背景 (ホバー)" driveFolderBg: "ドライブフォルダーの背景" wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 757afe53f9..414202adab 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?" retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다." enableChartsForRemoteUser: "리모트 유저의 차트를 생성" enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" +enableStatsForFederatedInstances: "리모트 서버 정보 받아오기" showClipButtonInNoteFooter: "노트 동작에 클립을 추가" reactionsDisplaySize: "리액션 표시 크기" limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기" @@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "패스키 검증을 실패했습니다." passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다." messageToFollower: "팔로워에 보낼 메시지" target: "대상" +testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. 실제 환경에서는 사용하지 마세요." +prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)" +prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다." +yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다." +yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요." _abuseUserReport: forward: "전달" forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다." @@ -1431,6 +1437,7 @@ _serverSettings: reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다." inquiryUrl: "문의처 URL" inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다." _accountMigration: moveFrom: "다른 계정에서 이 계정으로 이사" moveFromSub: "다른 계정에 대한 별칭을 생성" @@ -1984,7 +1991,6 @@ _theme: buttonBg: "버튼 배경" buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" - listItemHoverBg: "리스트 항목 배경 (호버)" driveFolderBg: "드라이브 폴더 배경" wallpaperOverlay: "배경화면 오버레이" badge: "배지" @@ -2486,6 +2492,8 @@ _webhookSettings: abuseReport: "유저롭" abuseReportResolved: "받은 신고를 처리했을 때" userCreated: "유저가 생성되었을 때" + inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우" + inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우" deleteConfirm: "Webhook을 삭제할까요?" testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다." _abuseReport: diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 117434ad32..d7afd57760 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1205,7 +1205,6 @@ _theme: buttonBg: "Tło przycisku" buttonHoverBg: "Tło przycisku (po najechaniu)" inputBorder: "Obramowanie pola wejścia" - listItemHoverBg: "Tło elementu listy (po najechaniu)" driveFolderBg: "Tło folderu na dysku" wallpaperOverlay: "Nakładka tapety" badge: "Odznaka" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 98d42eb44a..9039fd2141 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -25,7 +25,7 @@ basicSettings: "Configurações básicas" otherSettings: "Outras configurações" openInWindow: "Abrir em um janela" profile: "Perfil" -timeline: "Cronologia" +timeline: "Linha do tempo" noAccountDescription: "Este usuário não tem uma descrição." login: "Iniciar sessão" loggingIn: "Iniciando sessão…" @@ -1944,7 +1944,6 @@ _theme: buttonBg: "Plano de fundo de botão" buttonHoverBg: "Plano de fundo de botão (Selecionado)" inputBorder: "Borda de campo digitável" - listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)" driveFolderBg: "Plano de fundo da pasta no Drive" wallpaperOverlay: "Sobreposição do papel de parede." badge: "Emblema" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index befb537105..70178ec2fd 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1694,7 +1694,6 @@ _theme: buttonBg: "Фон кнопки" buttonHoverBg: "Текст кнопки" inputBorder: "Рамка поля ввода" - listItemHoverBg: "Фон пункта списка (под указателем)" driveFolderBg: "Фон папки «Диска»" wallpaperOverlay: "Слой обоев" badge: "Значок" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 8cb73e1303..60ce45a6b9 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -1108,7 +1108,6 @@ _theme: buttonBg: "Pozadie tlačidla" buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" inputBorder: "Okraj vstupného poľa" - listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)" driveFolderBg: "Pozadie priečinu disku" wallpaperOverlay: "Vrstvenie pozadia" badge: "Odznak" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 31eee2bccc..c70d448e2b 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1943,7 +1943,6 @@ _theme: buttonBg: "ปุ่มพื้นหลัง" buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" inputBorder: "เส้นขอบของช่องป้อนข้อมูล" - listItemHoverBg: "รายการไอเทมพื้นหลัง (โฮเวอร์)" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" badge: "ตรา" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 974508b3a7..f2262cd71f 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1302,7 +1302,6 @@ _theme: buttonBg: "Фон кнопки" buttonHoverBg: "Фон кнопки (при наведенні)" inputBorder: "Край поля вводу" - listItemHoverBg: "Фон елементу в списку (при наведенні)" driveFolderBg: "Фон папки на диску" wallpaperOverlay: "Накладання шпалер" badge: "Значок" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 6cf9b3f278..235497d844 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1546,7 +1546,6 @@ _theme: buttonBg: "Nền nút" buttonHoverBg: "Nền nút (Chạm)" inputBorder: "Đường viền khung soạn thảo" - listItemHoverBg: "Nền mục liệt kê (Chạm)" driveFolderBg: "Nền thư mục Ổ đĩa" wallpaperOverlay: "Lớp phủ hình nền" badge: "Huy hiệu" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 09feca5b4e..b81018cc1f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?" retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForFederatedInstances: "生成远程服务器的图表" +enableStatsForFederatedInstances: "获取远程服务器的信息" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" reactionsDisplaySize: "回应显示大小" limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示" @@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "验证通行密钥失败。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。" messageToFollower: "给关注者的消息" target: "对象" +testCaptchaWarning: "此功能为测试 CAPTCHA 用。请勿在正式环境中使用。" +prohibitedWordsForNameOfUser: "用户名中禁止的词" +prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。" +yourNameContainsProhibitedWords: "目标用户名包含违禁词" +yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。" _abuseUserReport: forward: "转发" forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。" @@ -1431,6 +1437,7 @@ _serverSettings: reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。" inquiryUrl: "联络地址" inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。" _accountMigration: moveFrom: "从别的账号迁移到此账户" moveFromSub: "为另一个账户建立别名" @@ -1984,7 +1991,6 @@ _theme: buttonBg: "按钮背景" buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" - listItemHoverBg: "下拉列表项目背景(悬停)" driveFolderBg: "网盘的文件夹背景" wallpaperOverlay: "壁纸叠加层" badge: "徽章" @@ -2263,7 +2269,7 @@ _profile: avatarDecorationMax: "最多可添加 {max} 个挂件" followedMessage: "被关注时显示的消息" followedMessageDescription: "可以设置被关注时向对方显示的短消息。" - followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在被请求被批准后显示。" + followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。" _exportOrImport: allNotes: "所有帖子" favoritedNotes: "收藏的帖子" @@ -2486,6 +2492,8 @@ _webhookSettings: abuseReport: "当收到举报时" abuseReportResolved: "当举报被处理时" userCreated: "当用户被创建时" + inactiveModeratorsWarning: "当管理员在一段时间内不活跃时" + inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时" deleteConfirm: "要删除 webhook 吗?" testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。" _abuseReport: diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 5e8a5d8f8d..de18342bbf 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -454,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼" moderator: "審查員" moderation: "審查" moderationNote: "管理筆記" +moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。" addModerationNote: "新增管理筆記" moderationLogs: "管理日誌" nUsersMentioned: "被 {n} 個人提及" @@ -519,7 +520,7 @@ menuStyle: "選單風格" style: "風格" drawer: "側邊欄" popup: "彈出式視窗" -showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" +showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的" showReactionsCount: "顯示貼文的反應數目" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" @@ -1018,7 +1019,7 @@ show: "檢視" neverShow: "不再顯示" remindMeLater: "以後再說" didYouLikeMisskey: "您喜歡 Misskey 嗎?" -pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!" +pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!" correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。" roles: "角色" role: "角色" @@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?" retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端使用者的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" +enableStatsForFederatedInstances: "取得遠端伺服器資訊" showClipButtonInNoteFooter: "新增摘錄按鈕至貼文" reactionsDisplaySize: "反應的顯示尺寸" limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。" @@ -1194,7 +1196,7 @@ showRenotes: "顯示其他人的轉發貼文" edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" -followingOrFollower: "追隨中或追隨者" +followingOrFollower: "追隨中或者追隨者" fileAttachedOnly: "只顯示包含附件的貼文" showRepliesToOthersInTimeline: "顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" @@ -1265,7 +1267,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊" keepOriginalFilename: "保留原始檔名" keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。" noDescription: "沒有說明文字" -alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息" +alwaysConfirmFollow: "跟隨時總是確認" inquiry: "聯絡我們" tryAgain: "請再試一次。" confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" @@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "未註冊的金鑰。" passkeyVerificationFailed: "驗證金鑰失敗。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。" messageToFollower: "給追隨者的訊息" +target: "目標 " +testCaptchaWarning: "此功能用於 CAPTCHA 的測試。請勿在正式環境中使用。" +prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)" +prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。" +yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串" +yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。" +_abuseUserReport: + forward: "轉發" + forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。" + resolve: "解決" + accept: "接受" + reject: "拒絕" + resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。" _delivery: status: "傳送狀態" stop: "停止發送" @@ -1422,6 +1437,7 @@ _serverSettings: reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。" inquiryUrl: "聯絡表單網址" inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1435,7 +1451,7 @@ _accountMigration: startMigration: "遷移" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" - postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" + postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。" movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" @@ -1555,7 +1571,7 @@ _achievements: _markedAsCat: title: "我是貓" description: "已將帳戶設定為貓" - flavor: "還沒有名字。" + flavor: "沒有名字。" _following1: title: "首次追隨" description: "首次追隨了" @@ -1569,7 +1585,7 @@ _achievements: title: "一百位朋友" description: "追隨超過100人了" _following300: - title: "朋友過多" + title: "朋友太多" description: "追隨超過300人了" _followers1: title: "第一個追隨者" @@ -1895,7 +1911,7 @@ _channel: following: "追隨中" usersCount: "有 {n} 人參與" notesCount: "有 {n} 篇貼文" - nameAndDescription: "名稱與說明" + nameAndDescription: "名稱" nameOnly: "僅名稱" allowRenoteToExternal: "允許在頻道外轉發和引用" _menuDisplay: @@ -1975,7 +1991,6 @@ _theme: buttonBg: "按鈕背景" buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" - listItemHoverBg: "列表物品背景 (漂浮)" driveFolderBg: "雲端硬碟文件夾背景" wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" @@ -2477,6 +2492,8 @@ _webhookSettings: abuseReport: "當使用者檢舉時" abuseReportResolved: "當處理了使用者的檢舉時" userCreated: "使用者被新增時" + inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時" + inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制" deleteConfirm: "請問是否要刪除 Webhook?" testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。" _abuseReport: @@ -2491,7 +2508,7 @@ _abuseReport: mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)" webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)" keywords: "關鍵字" - notifiedUser: "被通知的使用者" + notifiedUser: "通知的使用者" notifiedWebhook: "使用的 Webhook" deleteConfirm: "確定要刪除通知對象嗎?" _moderationLogTypes: @@ -2522,6 +2539,8 @@ _moderationLogTypes: markSensitiveDriveFile: "標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" resolveAbuseReport: "解決檢舉" + forwardAbuseReport: "轉發檢舉" + updateAbuseReportNote: "更新檢舉的審查備註" createInvitation: "建立邀請碼" createAd: "建立廣告" deleteAd: "刪除廣告" diff --git a/package.json b/package.json index 8cb783d883..59b75fece4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2024.10.0-beta.6", + "version": "2024.10.1-beta.6", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js new file mode 100644 index 0000000000..4ff520172b --- /dev/null +++ b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class EnableStatsForFederatedInstances1727318020265 { + name = 'EnableStatsForFederatedInstances1727318020265' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`); + } +} diff --git a/packages/backend/migration/1728550878802-testcaptcha.js b/packages/backend/migration/1728550878802-testcaptcha.js new file mode 100644 index 0000000000..d8d987c0c1 --- /dev/null +++ b/packages/backend/migration/1728550878802-testcaptcha.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Testcaptcha1728550878802 { + name = 'Testcaptcha1728550878802' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`); + } +} diff --git a/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js new file mode 100644 index 0000000000..36e698d120 --- /dev/null +++ b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ProhibitedWordsForNameOfUser1728634286056 { + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`); + } +} diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index fb7c7bd2c3..25e265f2b1 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -61,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } - const moderatorIds = await this.roleService.getModeratorIds(true, true); + const moderatorIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + excludeExpire: true, + }); for (const moderatorId of moderatorIds) { for (const abuseReport of abuseReports) { @@ -285,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .log(updater, 'createAbuseReportNotificationRecipient', { recipientId: id, recipient: created, - }) - .then(); + }); return created; } @@ -324,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { recipientId: params.id, before: beforeEntity, after: afterEntity, - }) - .then(); + }); return afterEntity; } @@ -346,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .log(updater, 'deleteAbuseReportNotificationRecipient', { recipientId: id, recipient: entity, - }) - .then(); + }); } /** @@ -370,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { } // モデレータ権限の有無で通知先設定を振り分ける - const authorizedUserIds = await this.roleService.getModeratorIds(true, true); + const authorizedUserIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + excludeExpire: true, + }); const authorizedUserRecipients = Array.of(); const unauthorizedUserRecipients = Array.of(); for (const recipient of userRecipients) { diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts index 73baad5499..0b022d3b08 100644 --- a/packages/backend/src/core/AbuseReportService.ts +++ b/packages/backend/src/core/AbuseReportService.ts @@ -110,8 +110,7 @@ export class AbuseReportService { reportId: report.id, report: report, resolvedAs: ps.resolvedAs, - }) - .then(); + }); } return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) }) @@ -148,8 +147,7 @@ export class AbuseReportService { .log(moderator, 'forwardAbuseReport', { reportId: report.id, report: report, - }) - .then(); + }); } @bindThis diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 6e3125044c..24d11f29ff 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -274,13 +274,15 @@ export class AccountMoveService { } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. - if (this.userEntityService.isRemoteUser(oldAccount)) { - this.federatedInstanceService.fetch(oldAccount.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(oldAccount)) { + this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } } // FIXME: expensive? diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 40a9db01c0..d4fcf19439 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -209,6 +209,13 @@ export class AnnouncementService { return; } + const announcement = await this.announcementsRepository.findOneBy({ id: announcementId }); + if (announcement != null && announcement.userId === user.id) { + await this.announcementsRepository.update(announcementId, { + isActive: false, + }); + } + if ((await this.getUnreadAnnouncements(user)).length === 0) { this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); } diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f6b7955cd2..206d0dbe0a 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -119,5 +119,18 @@ export class CaptchaService { throw new Error(`turnstile-failed: ${errorCodes}`); } } + + @bindThis + public async verifyTestcaptcha(response: string | null | undefined): Promise { + if (response == null) { + throw new Error('testcaptcha-failed: no response provided'); + } + + const success = response === 'testcaptcha-passed'; + + if (!success) { + throw new Error('testcaptcha-failed'); + } + } } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 5db3c5b980..4566113449 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -103,19 +103,33 @@ export class CustomEmojiService implements OnApplicationShutdown { } @bindThis - public async update(id: MiEmoji['id'], data: { + public async update(data: ( + { id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], } + ) & { driveFile?: MiDriveFile; - name?: string; category?: string | null; aliases?: string[]; license?: string | null; isSensitive?: boolean; localOnly?: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; - }, moderator?: MiUser): Promise { - const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); - const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); - if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + }, moderator?: MiUser): Promise< + null + | 'NO_SUCH_EMOJI' + | 'SAME_NAME_EMOJI_EXISTS' + > { + const emoji = data.id + ? await this.getEmojiById(data.id) + : await this.getEmojiByName(data.name!); + if (emoji === null) return 'NO_SUCH_EMOJI'; + const id = emoji.id; + + // IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要 + const doNameUpdate = data.id && data.name && (data.name !== emoji.name); + if (doNameUpdate) { + const isDuplicate = await this.checkDuplicate(data.name!); + if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS'; + } await this.emojisRepository.update(emoji.id, { updatedAt: new Date(), @@ -135,7 +149,7 @@ export class CustomEmojiService implements OnApplicationShutdown { const packed = await this.emojiEntityService.packDetailed(emoji.id); - if (emoji.name === data.name) { + if (!doNameUpdate) { this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: [packed], }); @@ -157,6 +171,7 @@ export class CustomEmojiService implements OnApplicationShutdown { after: updated, }); } + return null; } @bindThis diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 7aeeb78178..73bbf03b26 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { } @bindThis - public async fetch(host: string): Promise { + public async fetchOrRegister(host: string): Promise { host = this.utilityService.toPuny(host); const cached = await this.federatedInstanceCache.get(host); @@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown { } } + @bindThis + public async fetch(host: string): Promise { + host = this.utilityService.toPuny(host); + + const cached = await this.federatedInstanceCache.get(host); + if (cached !== undefined) return cached; + + const index = await this.instancesRepository.findOneBy({ host }); + + if (index == null) { + this.federatedInstanceCache.set(host, null); + return null; + } else { + this.federatedInstanceCache.set(host, index); + return index; + } + } + @bindThis public async update(id: MiInstance['id'], data: Partial): Promise { const result = await this.instancesRepository.createQueryBuilder().update() diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index dc53c8711d..fe97f38528 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -79,7 +79,7 @@ export class FetchInstanceMetadataService { if (!force) { // キャッシュ有効チェックはロック取得前に行う - const _instance = await this.federatedInstanceService.fetch(host); + const _instance = await this.federatedInstanceService.fetchOrRegister(host); const now = Date.now(); if (_instance && _instance.infoUpdatedAt != null && (now - _instance.infoUpdatedAt.getTime() < REMOTE_SERVER_CACHE_TTL)) { this.logger.debug(`Skip because updated recently ${_instance.infoUpdatedAt.toJSON()}`); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 0ce57f16e6..3647fa7231 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -511,13 +511,15 @@ export class NoteCreateService implements OnApplicationShutdown { } // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.updateNotesCountQueue.enqueue(i.id, 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + this.updateNotesCountQueue.enqueue(i.id, 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } + }); + } } // ハッシュタグ更新 diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index f9f8ace386..4ecd2592b2 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -106,13 +106,15 @@ export class NoteDeleteService { this.perUserNotesChart.update(user, note, false); } - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } + }); + } } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index c1e7c96d84..ae31fe18d3 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -92,6 +92,13 @@ export class QueueService { repeat: { pattern: '0 0 * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('checkModeratorsActivity', { + }, { + // 毎時30分に起動 + repeat: { pattern: '30 * * * *' }, + removeOnComplete: true, + }); } @bindThis diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 583eea1a34..5af6b05942 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown, OnModuleInit { + private rootUserIdCache: MemorySingleCache; private rolesCache: MemorySingleCache; private roleAssignmentByUserIdCache: MemoryKVCache; private notificationService: NotificationService; @@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private moderationLogService: ModerationLogService, private fanoutTimelineService: FanoutTimelineService, ) { + this.rootUserIdCache = new MemorySingleCache(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m @@ -416,49 +418,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async isExplorable(role: { id: MiRole['id']} | null): Promise { + public async isExplorable(role: { id: MiRole['id'] } | null): Promise { if (role == null) return false; const check = await this.rolesRepository.findOneBy({ id: role.id }); if (check == null) return false; return check.isExplorable; } + /** + * モデレーター権限のロールが割り当てられているユーザID一覧を取得する. + * + * @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true) + * @param opts.includeRoot rootユーザも含めるか(デフォルト: false) + * @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false) + */ @bindThis - public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise { + public async getModeratorIds(opts?: { + includeAdmins?: boolean, + includeRoot?: boolean, + excludeExpire?: boolean, + }): Promise { + const includeAdmins = opts?.includeAdmins ?? true; + const includeRoot = opts?.includeRoot ?? false; + const excludeExpire = opts?.excludeExpire ?? false; + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); - // TODO: isRootなアカウントも含める const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) : []; + // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) const now = Date.now(); - const result = [ - // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) - ...new Set( - assigns - .filter(it => - (excludeExpire) - ? (it.expiresAt == null || it.expiresAt.getTime() > now) - : true, - ) - .map(a => a.userId), - ), - ]; + const resultSet = new Set( + assigns + .filter(it => + (excludeExpire) + ? (it.expiresAt == null || it.expiresAt.getTime() > now) + : true, + ) + .map(a => a.userId), + ); - return result.sort((x, y) => x.localeCompare(y)); + if (includeRoot) { + const rootUserId = await this.rootUserIdCache.fetch(async () => { + const it = await this.usersRepository.createQueryBuilder('users') + .select('id') + .where({ isRoot: true }) + .getRawOne<{ id: string }>(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return it!.id; + }); + resultSet.add(rootUserId); + } + + return [...resultSet].sort((x, y) => x.localeCompare(y)); } @bindThis - public async getModerators(includeAdmins = true): Promise { - const ids = await this.getModeratorIds(includeAdmins); - const users = ids.length > 0 ? await this.usersRepository.findBy({ - id: In(ids), - }) : []; - return users; + public async getModerators(opts?: { + includeAdmins?: boolean, + includeRoot?: boolean, + excludeExpire?: boolean, + }): Promise { + const ids = await this.getModeratorIds(opts); + return ids.length > 0 + ? await this.usersRepository.findBy({ + id: In(ids), + }) + : []; } @bindThis diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 16424fc133..05212eef19 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -134,8 +134,8 @@ export class SignupService { })); }); - this.usersChart.update(account, true).then(); - this.userService.notifySystemWebhook(account, 'userCreated').then(); + this.usersChart.update(account, true); + this.userService.notifySystemWebhook(account, 'userCreated'); return { account, secret }; } diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts index bb7c6b8c0e..db6407dcb3 100644 --- a/packages/backend/src/core/SystemWebhookService.ts +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown { .log(updater, 'createSystemWebhook', { systemWebhookId: webhook.id, webhook: webhook, - }) - .then(); + }); return webhook; } @@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown { systemWebhookId: beforeEntity.id, before: beforeEntity, after: afterEntity, - }) - .then(); + }); return afterEntity; } @@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown { .log(updater, 'deleteSystemWebhook', { systemWebhookId: webhook.id, webhook, - }) - .then(); + }); } /** diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 77e7b60bea..8963003057 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit { //#endregion //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } + }); + } } //#endregion @@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit { //#endregion //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } } //#endregion diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 4c45b95a64..55c8a52705 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js'; import { type WebhookEventTypes } from '@/models/Webhook.js'; import { UserWebhookService } from '@/core/UserWebhookService.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; const oneDayMillis = 24 * 60 * 60 * 1000; @@ -446,6 +447,22 @@ export class WebhookTestService { send(toPackedUserLite(dummyUser1)); break; } + case 'inactiveModeratorsWarning': { + const dummyTime: ModeratorInactivityRemainingTime = { + time: 100000, + asDays: 1, + asHours: 24, + }; + + send({ + remainingTime: dummyTime, + }); + break; + } + case 'inactiveModeratorsInvitationOnlyChanged': { + send({}); + break; + } } } } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 3c4e082552..bb38b1e810 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -466,13 +466,15 @@ export class ApPersonService implements OnModuleInit { this.cacheService.uriPersonCache.set(user.uri, user); // Register host - this.federatedInstanceService.fetch(host).then(i => { - this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.newUser(i.host); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + this.federatedInstanceService.fetchOrRegister(host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.newUser(i.host); + } + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + }); + } this.usersChart.update(user, true); diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index fbd982eb34..409dca3426 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -96,6 +96,7 @@ export class MetaEntityService { recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index c64e9151a7..3e1f094fce 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -22,6 +22,30 @@ import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; +// is-renote.tsとよしなにリンク +function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } { + return ( + note.renote != null && + note.reply == null && + note.text == null && + note.cw == null && + (note.fileIds == null || note.fileIds.length === 0) && + !note.hasPoll + ); +} + +function getAppearNoteIds(notes: MiNote[]): Set { + const appearNoteIds = new Set(); + for (const note of notes) { + if (isPureRenote(note)) { + appearNoteIds.add(note.renoteId); + } else { + appearNoteIds.add(note.id); + } + } + return appearNoteIds; +} + @Injectable() export class NoteEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -90,7 +114,7 @@ export class NoteEntityService implements OnModuleInit { hide = false; } else { // 指定されているかどうか - const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + const specified = packedNote.visibleUserIds!.some(id => meId === id); if (specified) { hide = false; @@ -227,7 +251,7 @@ export class NoteEntityService implements OnModuleInit { return true; } else { // 指定されているかどうか - return note.visibleUserIds.some((id: any) => meId === id); + return note.visibleUserIds.some(id => meId === id); } } @@ -421,7 +445,7 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; - const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null; + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; const meId = me ? me.id : null; const myReactionsMap = new Map(); @@ -432,7 +456,7 @@ export class NoteEntityService implements OnModuleInit { const oldId = this.idService.gen(Date.now() - 2000); for (const note of notes) { - if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote + if (isPureRenote(note)) { const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.renote.id, null); diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index d29689f907..ad5e31ad6f 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -81,6 +81,11 @@ export class MiMeta { }) public prohibitedWords: string[]; + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public prohibitedWordsForNameOfUser: string[]; + @Column('varchar', { length: 1024, array: true, default: '{}', }) @@ -258,6 +263,11 @@ export class MiMeta { }) public turnstileSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableTestcaptcha: boolean; + // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること @Column('enum', { @@ -519,6 +529,11 @@ export class MiMeta { }) public enableChartsForFederatedInstances: boolean; + @Column('boolean', { + default: true, + }) + public enableStatsForFederatedInstances: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts index d6c27eae51..1a7ce4962b 100644 --- a/packages/backend/src/models/SystemWebhook.ts +++ b/packages/backend/src/models/SystemWebhook.ts @@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [ 'abuseReportResolved', // ユーザが作成された時 'userCreated', + // モデレータが一定期間不在である警告 + 'inactiveModeratorsWarning', + // モデレータが一定期間不在のためシステムにより招待制へと変更された + 'inactiveModeratorsInvitationOnlyChanged', ] as const; export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 99feeaa7d7..e3fd63464a 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -115,6 +115,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 0027b5ef3d..9044285bf6 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -6,6 +6,7 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; import { GlobalModule } from '@/GlobalModule.js'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; @@ -80,6 +81,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, + CheckExpiredMutingsProcessorService, + CheckModeratorsActivityProcessorService, QueueProcessorService, ], exports: [ diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 9586abbc82..fcdeb00b9e 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -10,6 +10,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -66,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts ? job.opts.attempts : 0; + const maxAttempts = job.opts.attempts ?? 0; return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; } @@ -120,24 +121,35 @@ export class QueueProcessorService implements OnApplicationShutdown { private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, + private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; - function renderError(e: Error): any { - if (e) { // 何故かeがundefinedで来ることがある - return { - stack: e.stack, - message: e.message, - name: e.name, - }; - } else { - return { - stack: '?', - message: '?', - name: '?', - }; + function renderError(e?: Error) { + // 何故かeがundefinedで来ることがある + if (!e) return '?'; + + if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') { + return `${e.name}: ${e.message}`; } + + return { + stack: e.stack, + message: e.message, + name: e.name, + }; + } + + function renderJob(job?: Bull.Job) { + if (!job) return '?'; + + return { + name: job.name || undefined, + info: getJobInfo(job), + failedReason: job.failedReason || undefined, + data: job.data, + }; } //#region system @@ -150,6 +162,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); + case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } @@ -172,15 +185,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -229,15 +242,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -269,15 +282,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { + Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -309,15 +322,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { + Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -349,15 +362,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -389,15 +402,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -436,15 +449,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -477,15 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts new file mode 100644 index 0000000000..87183cb342 --- /dev/null +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { MiUser, type UserProfilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +// モデレーターが不在と判断する日付の閾値 +const MODERATOR_INACTIVITY_LIMIT_DAYS = 7; +// 警告通知やログ出力を行う残日数の閾値 +const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2; +// 期限から6時間ごとに通知を行う +const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6; +const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60; +const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24; + +export type ModeratorInactivityEvaluationResult = { + isModeratorsInactive: boolean; + inactiveModerators: MiUser[]; + remainingTime: ModeratorInactivityRemainingTime; +} + +export type ModeratorInactivityRemainingTime = { + time: number; + asHours: number; + asDays: number; +}; + +function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) { + const subject = 'Moderator Inactivity Warning / モデレーター不在の通知'; + + const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`; + const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`; + const message = [ + 'To Moderators,', + '', + `A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`, + 'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.', + '', + '---------------', + '', + 'To モデレーター各位', + '', + `モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`, + '招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。', + '', + ]; + + const html = message.join('
'); + const text = message.join('\n'); + + return { + subject, + html, + text, + }; +} + +function generateInvitationOnlyChangedMail() { + const subject = 'Change to Invitation-Only / 招待制に変更されました'; + + const message = [ + 'To Moderators,', + '', + `Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`, + 'To cancel the invitation only, you need to access the control panel.', + '', + '---------------', + '', + 'To モデレーター各位', + '', + `モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`, + '招待制を解除するには、コントロールパネルにアクセスする必要があります。', + '', + ]; + + const html = message.join('
'); + const text = message.join('\n'); + + return { + subject, + html, + text, + }; +} + +@Injectable() +export class CheckModeratorsActivityProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private metaService: MetaService, + private roleService: RoleService, + private emailService: EmailService, + private announcementService: AnnouncementService, + private systemWebhookService: SystemWebhookService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity'); + } + + @bindThis + public async process(): Promise { + this.logger.info('start.'); + + const meta = await this.metaService.fetch(false); + if (!meta.disableRegistration) { + await this.processImpl(); + } else { + this.logger.info('is already invitation only.'); + } + + this.logger.succ('finish.'); + } + + @bindThis + private async processImpl() { + const evaluateResult = await this.evaluateModeratorsInactiveDays(); + if (evaluateResult.isModeratorsInactive) { + this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`); + + await this.changeToInvitationOnly(); + await this.notifyChangeToInvitationOnly(); + } else { + const remainingTime = evaluateResult.remainingTime; + if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) { + const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`; + this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`); + + if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) { + // ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する + // つまり、のこり2日を切ったら6時間ごとに通知が送られる + await this.notifyInactiveModeratorsWarning(remainingTime); + } + } + } + } + + /** + * モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。 + * isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、 + * {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。 + * {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。 + * + * ----- + * + * ### サンプルパターン + * - 実行日時: 2022-01-30 12:00:00 + * - 判定基準: 2022-01-23 12:00:00(実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前) + * + * #### パターン① + * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト + * - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ(判定基準と同値なのでギリギリ残り0日) + * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日) + * - モデレータD: lastActiveDate = null + * + * この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。 + * + * #### パターン② + * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト + * - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日) + * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日) + * - モデレータD: lastActiveDate = null + * + * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。 + */ + @bindThis + public async evaluateModeratorsInactiveDays(): Promise { + const today = new Date(); + const inactivePeriod = new Date(today); + inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS); + + const moderators = await this.fetchModerators() + .then(it => it.filter(it => it.lastActiveDate != null)); + const inactiveModerators = moderators + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime()); + + // 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime()))); + const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime(); + const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC); + const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC)); + + return { + isModeratorsInactive: inactiveModerators.length === moderators.length, + inactiveModerators, + remainingTime: { + time: remainingTime, + asHours: remainingTimeAsHours, + asDays: remainingTimeAsDays, + }, + }; + } + + @bindThis + private async changeToInvitationOnly() { + await this.metaService.update({ disableRegistration: true }); + } + + @bindThis + public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) { + // -- モデレータへのメール送信 + + const moderators = await this.fetchModerators(); + const moderatorProfiles = await this.userProfilesRepository + .findBy({ userId: In(moderators.map(it => it.id)) }) + .then(it => new Map(it.map(it => [it.userId, it]))); + + const mail = generateModeratorInactivityMail(remainingTime); + for (const moderator of moderators) { + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text); + } + } + + // -- SystemWebhook + + const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks() + .then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning'))); + for (const systemWebhook of systemWebhooks) { + this.systemWebhookService.enqueueSystemWebhook( + systemWebhook, + 'inactiveModeratorsWarning', + { remainingTime: remainingTime }, + ); + } + } + + @bindThis + public async notifyChangeToInvitationOnly() { + // -- モデレータへのメールとお知らせ(個人向け)送信 + + const moderators = await this.fetchModerators(); + const moderatorProfiles = await this.userProfilesRepository + .findBy({ userId: In(moderators.map(it => it.id)) }) + .then(it => new Map(it.map(it => [it.userId, it]))); + + const mail = generateInvitationOnlyChangedMail(); + for (const moderator of moderators) { + this.announcementService.create({ + title: mail.subject, + text: mail.text, + forExistingUsers: true, + needConfirmationToRead: true, + userId: moderator.id, + }); + + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text); + } + } + + // -- SystemWebhook + + const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks() + .then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged'))); + for (const systemWebhook of systemWebhooks) { + this.systemWebhookService.enqueueSystemWebhook( + systemWebhook, + 'inactiveModeratorsInvitationOnlyChanged', + {}, + ); + } + } + + @bindThis + private async fetchModerators() { + // TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する + return this.roleService.getModerators({ + includeAdmins: true, + includeRoot: true, + excludeExpire: true, + }); + } +} diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 8bb9164fbd..3866a54151 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -72,20 +72,25 @@ export class DeliverProcessorService { } try { - const _server = await this.federatedInstanceService.fetch(host); - await this.fetchInstanceMetadataService.fetchInstanceMetadata(_server).then(() => {}); - const server = await this.federatedInstanceService.fetch(host); + const server = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(host) + : this.federatedInstanceService.fetch(host)); await this.apRequestService.signedPost( job.data.user, job.data.to, job.data.content, - server.httpMessageSignaturesImplementationLevel, + server?.httpMessageSignaturesImplementationLevel ?? '00', job.data.digest, job.data.privateKey, ); // Update stats + this.apRequestChart.deliverSucc(); + this.federationChart.deliverd(host, true); + + if (server == null) return 'Success'; + if (server.isNotResponding) { this.federatedInstanceService.update(server.id, { isNotResponding: false, @@ -93,17 +98,17 @@ export class DeliverProcessorService { }); } - this.apRequestChart.deliverSucc(); - this.federationChart.deliverd(server.host, true); - if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(server.host, true); } return 'Success'; } catch (res) { - // Update stats - this.federatedInstanceService.fetch(host).then(i => { + this.apRequestChart.deliverFail(); + this.federationChart.deliverd(host, false); + + // Update instance stats + this.federatedInstanceService.fetchOrRegister(host).then(i => { if (!i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: true, @@ -124,9 +129,6 @@ export class DeliverProcessorService { }); } - this.apRequestChart.deliverFail(); - this.federationChart.deliverd(i.host, false); - if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, false); } @@ -142,7 +144,7 @@ export class DeliverProcessorService { if (!res.isRetryable) { // 相手が閉鎖していることを明示しているため、配送停止する if (job.data.isSharedInbox && res.statusCode === 410) { - this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.fetchOrRegister(host).then(i => { this.federatedInstanceService.update(i.id, { suspensionState: 'goneSuspended', }); diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 61d786101f..646c7f3a1e 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -207,21 +207,27 @@ export class InboxProcessorService implements OnApplicationShutdown { } } - // Update stats - this.federatedInstanceService.fetch(authUser.user.host).then(i => { + this.apRequestChart.inbox(); + this.federationChart.inbox(authUser.user.host); + + // Update instance stats + process.nextTick(async () => { + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(authUser.user.host) + : this.federatedInstanceService.fetch(authUser.user.host)); + + if (i == null) return; + this.updateInstanceQueue.enqueue(i.id, { latestRequestReceivedAt: new Date(), shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', }); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.apRequestChart.inbox(); - this.federationChart.inbox(i.host); - if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestReceived(i.host); } + + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); }); // アクティビティを処理 diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index be63635efe..3a8cb19f01 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -119,6 +119,7 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); @@ -132,6 +133,7 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 0d24ffa56a..1d983ca4bc 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -71,6 +71,7 @@ export class SigninApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>, reply: FastifyReply, @@ -194,6 +195,12 @@ export class SigninApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } if (same) { diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index c499638018..3ec5e5d3e6 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -67,6 +67,7 @@ export class SignupApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>, reply: FastifyReply, @@ -99,6 +100,12 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } const username = body['username']; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 22609a16a3..212cba5c5d 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiEmoji } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -78,25 +78,14 @@ export default class extends Endpoint { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); } - let emojiId; - if (ps.id) { - emojiId = ps.id; - const emoji = await this.customEmojiService.getEmojiById(ps.id); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - if (ps.name && (ps.name !== emoji.name)) { - const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); - if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); - } - } else { - if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.'); - const emoji = await this.customEmojiService.getEmojiByName(ps.name); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - emojiId = emoji.id; - } + // JSON schemeのanyOfの型変換がうまくいっていないらしい + const required = { id: ps.id, name: ps.name } as + | { id: MiEmoji['id']; name?: string } + | { id?: MiEmoji['id']; name: string }; - await this.customEmojiService.update(emojiId, { + const error = await this.customEmojiService.update({ + ...required, driveFile, - name: ps.name, category: ps.category, aliases: ps.aliases, license: ps.license, @@ -104,6 +93,14 @@ export default class extends Endpoint { // eslint- localOnly: ps.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, }, me); + + switch (error) { + case null: return; + case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji); + case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); + } + // 網羅性チェック + const mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index b76ed5c524..64e3cc33bd 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -69,6 +69,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -173,6 +177,13 @@ export const meta = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, bannedEmailDomains: { type: 'array', optional: true, nullable: false, @@ -337,6 +348,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableStatsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, enableServerMachineStats: { type: 'boolean', optional: false, nullable: false, @@ -555,6 +570,7 @@ export default class extends Endpoint { // eslint- recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -581,6 +597,7 @@ export default class extends Endpoint { // eslint- mediaSilencedHosts: instance.mediaSilencedHosts, sensitiveWords: instance.sensitiveWords, prohibitedWords: instance.prohibitedWords, + prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser, preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey, @@ -622,6 +639,7 @@ export default class extends Endpoint { // eslint- truemailAuthKey: instance.truemailAuthKey, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, + enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, enableIdenticonGeneration: instance.enableIdenticonGeneration, bannedEmailDomains: instance.bannedEmailDomains, diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 2fef9abbf9..2b2c8c60ab 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -71,13 +71,13 @@ export default class extends Endpoint { // eslint- break; } case 'moderator': { - const moderatorIds = await this.roleService.getModeratorIds(false); + const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false }); if (moderatorIds.length === 0) return []; query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); break; } case 'adminOrModerator': { - const adminOrModeratorIds = await this.roleService.getModeratorIds(); + const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true }); if (adminOrModeratorIds.length === 0) return []; query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); break; diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 9ffae840b6..38ef0d1de8 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -46,6 +46,11 @@ export const paramDef = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -78,6 +83,7 @@ export const paramDef = { enableTurnstile: { type: 'boolean' }, turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, + enableTestcaptcha: { type: 'boolean' }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -130,6 +136,7 @@ export const paramDef = { truemailAuthKey: { type: 'string', nullable: true }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + enableStatsForFederatedInstances: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' }, enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, @@ -213,6 +220,9 @@ export default class extends Endpoint { // eslint- if (Array.isArray(ps.prohibitedWords)) { set.prohibitedWords = ps.prohibitedWords.filter(Boolean); } + if (Array.isArray(ps.prohibitedWordsForNameOfUser)) { + set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean); + } if (Array.isArray(ps.silencedHosts)) { let lastValue = ''; set.silencedHosts = ps.silencedHosts.sort().filter((h) => { @@ -357,6 +367,10 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } + if (ps.enableTestcaptcha !== undefined) { + set.enableTestcaptcha = ps.enableTestcaptcha; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } @@ -565,6 +579,10 @@ export default class extends Endpoint { // eslint- set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.enableStatsForFederatedInstances !== undefined) { + set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances; + } + if (ps.enableServerMachineStats !== undefined) { set.enableServerMachineStats = ps.enableServerMachineStats; } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 798bd98cf1..0b35005a87 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; -import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; +import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; @@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js'; @@ -114,6 +115,13 @@ export const meta = { code: 'RESTRICTED_BY_ROLE', id: '8feff0ba-5ab5-585b-31f4-4df816663fad', }, + + nameContainsProhibitedWords: { + message: 'Your new name contains prohibited words.', + code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS', + id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', + httpStatusCode: 422, + }, }, res: { @@ -223,6 +231,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private instanceMeta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -247,6 +258,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private httpRequestService: HttpRequestService, private avatarDecorationService: AvatarDecorationService, + private utilityService: UtilityService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -449,6 +461,14 @@ export default class extends Endpoint { // eslint- const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; if (newName != null) { + let hasProhibitedWords = false; + if (!await this.roleService.isModerator(user)) { + hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser); + } + if (hasProhibitedWords) { + throw new ApiError(meta.errors.nameContainsProhibitedWords); + } + const tokens = mfm.parseSimple(newName); emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); } diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 7c6a533429..a04640d993 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -98,7 +98,7 @@ const theme = localStorage.getItem('theme'); if (theme) { for (const [k, v] of Object.entries(JSON.parse(theme))) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); // HTMLの theme-color 適用 if (k === 'htmlThemeColor') { diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index dbcc8f537c..5d81f2bed0 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -5,8 +5,8 @@ */ html { - background-color: var(--bg); - color: var(--fg); + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); } #splash { @@ -17,7 +17,7 @@ html { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); opacity: 1; transition: opacity 0.5s ease; } @@ -45,7 +45,7 @@ html { width: 28px; height: 28px; transform: translateY(70px); - color: var(--accent); + color: var(--MI_THEME-accent); } #splashSpinner > .spinner { diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css index a7b110d80a..5e8786cc4e 100644 --- a/packages/backend/src/server/web/style.embed.css +++ b/packages/backend/src/server/web/style.embed.css @@ -5,8 +5,8 @@ */ html { - background-color: var(--bg); - color: var(--fg); + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); } html.embed { @@ -24,7 +24,7 @@ html.embed { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); opacity: 1; transition: opacity 0.5s ease; } @@ -33,7 +33,7 @@ html.embed #splash { box-sizing: border-box; min-height: 300px; border-radius: var(--radius, 12px); - border: 1px solid var(--divider, #e8e8e8); + border: 1px solid var(--MI_THEME-divider, #e8e8e8); } html.embed.norounded #splash { @@ -67,7 +67,7 @@ html.embed.noborder #splash { width: 28px; height: 28px; transform: translateY(70px); - color: var(--accent); + color: var(--MI_THEME-accent); } #splashSpinner > .spinner { diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index 2e66b81fcd..3febed4101 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; import { Redis } from 'ioredis'; +import type { TestingModule } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; function mockRedis() { const hash = {} as any; @@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => { if (token === HttpRequestService) { return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() }; } else if (token === FederatedInstanceService) { - return { fetch: jest.fn() }; + return { fetchOrRegister: jest.fn() }; } else if (token === DI.redis) { return mockRedis; } @@ -75,13 +75,13 @@ describe('FetchInstanceMetadataService', () => { test('Lock and update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalled(); @@ -89,13 +89,13 @@ describe('FetchInstanceMetadataService', () => { test('Don\'t lock and update if recently updated', async () => { redisClient.set = mockRedis(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date() } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: new Date() } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); @@ -104,14 +104,14 @@ describe('FetchInstanceMetadataService', () => { test('Do nothing when lock not acquired', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); @@ -120,14 +120,14 @@ describe('FetchInstanceMetadataService', () => { test('Do when forced', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0); expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalled(); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index ef80d25f81..9c1b1008d6 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -10,6 +10,8 @@ import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { @@ -31,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; import { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); @@ -277,9 +277,9 @@ describe('RoleService', () => { }); describe('getModeratorIds', () => { - test('includeAdmins = false, excludeExpire = false', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -295,13 +295,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(false, false); + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: false, + excludeExpire: false, + }); expect(result).toEqual([modeUser1.id, modeUser2.id]); }); - test('includeAdmins = false, excludeExpire = true', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -317,13 +321,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(false, true); + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: false, + excludeExpire: true, + }); expect(result).toEqual([modeUser1.id]); }); - test('includeAdmins = true, excludeExpire = false', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -339,13 +347,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(true, false); + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: false, + excludeExpire: false, + }); expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]); }); - test('includeAdmins = true, excludeExpire = true', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -361,9 +373,111 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(true, true); + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: false, + excludeExpire: true, + }); expect(result).toEqual([adminUser1.id, modeUser1.id]); }); + + test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]); + }); + + test('root has moderator role', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: rootUser.id, roleId: role2.id }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([modeUser1.id, rootUser.id]); + }); + + test('root has administrator role', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: rootUser.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]); + }); + + test('root has moderator role(expire)', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser({ isRoot: true }), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: true, + }); + expect(result).toEqual([rootUser.id]); + }); }); describe('conditional role', () => { diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts new file mode 100644 index 0000000000..1506283a3c --- /dev/null +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -0,0 +1,379 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as lolex from '@sinonjs/fake-timers'; +import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; +import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '@/queue/QueueLoggerService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; + +const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0)); + +describe('CheckModeratorsActivityProcessorService', () => { + let app: TestingModule; + let clock: lolex.InstalledClock; + let service: CheckModeratorsActivityProcessorService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let idService: IdService; + let roleService: jest.Mocked; + let announcementService: jest.Mocked; + let emailService: jest.Mocked; + let systemWebhookService: jest.Mocked; + + let systemWebhook1: MiSystemWebhook; + let systemWebhook2: MiSystemWebhook; + let systemWebhook3: MiSystemWebhook; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}, profile: Partial = {}): Promise { + const id = idService.gen(); + const user = await usersRepository + .insert({ + id: id, + username: `user_${id}`, + usernameLower: `user_${id}`.toLowerCase(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + ...profile, + }); + + return user; + } + + function crateSystemWebhook(data: Partial = {}): MiSystemWebhook { + return { + id: idService.gen(), + isActive: true, + updatedAt: new Date(), + latestSentAt: null, + latestStatus: null, + name: 'test', + url: 'https://example.com', + secret: 'test', + on: [], + ...data, + }; + } + + function mockModeratorRole(users: MiUser[]) { + roleService.getModerators.mockReset(); + roleService.getModerators.mockResolvedValue(users); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CheckModeratorsActivityProcessorService, + IdService, + { + provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), + }, + { + provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }), + }, + { + provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ + fetchActiveSystemWebhooks: jest.fn(), + enqueueSystemWebhook: jest.fn(), + }), + }, + { + provide: QueueLoggerService, useFactory: () => ({ + logger: ({ + createSubLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + succ: jest.fn(), + }), + }), + }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + + service = app.get(CheckModeratorsActivityProcessorService); + idService = app.get(IdService); + roleService = app.get(RoleService) as jest.Mocked; + announcementService = app.get(AnnouncementService) as jest.Mocked; + emailService = app.get(EmailService) as jest.Mocked; + systemWebhookService = app.get(SystemWebhookService) as jest.Mocked; + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + clock = lolex.install({ + now: new Date(baseDate), + shouldClearNativeTimers: true, + }); + + systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] }); + systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] }); + systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] }); + + emailService.sendEmail.mockReturnValue(Promise.resolve()); + announcementService.create.mockReturnValue(Promise.resolve({} as never)); + systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]); + systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never)); + }); + + afterEach(async () => { + clock.uninstall(); + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + roleService.getModerators.mockReset(); + announcementService.create.mockReset(); + emailService.sendEmail.mockReset(); + systemWebhookService.enqueueSystemWebhook.mockReset(); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('evaluateModeratorsInactiveDays', () => { + test('[isModeratorsInactive] inactiveなモデレーターがいても他のモデレーターがアクティブなら"運営が非アクティブ"としてみなされない', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + // 期限よりも1秒新しいタイミングでアクティブ化(セーフ) + createUser({ lastActiveDate: subDays(addSeconds(baseDate, 1), 7) }), + // 期限ちょうどにアクティブ化(セーフ) + createUser({ lastActiveDate: subDays(baseDate, 7) }), + // 期限よりも1秒古いタイミングでアクティブ化(アウト) + createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }), + // 対象外 + createUser({ lastActiveDate: null }), + ]); + + mockModeratorRole([user1, user2, user3, user4]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user3]); + }); + + test('[isModeratorsInactive] 全員非アクティブなら"運営が非アクティブ"としてみなされる', async () => { + const [user1, user2] = await Promise.all([ + // 期限よりも1秒古いタイミングでアクティブ化(アウト) + createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }), + // 対象外 + createUser({ lastActiveDate: null }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1]); + }); + + test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り24時間->猶予1日として計算されるはずである + createUser({ lastActiveDate: subDays(baseDate, 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(1); + expect(result.remainingTime.asHours).toBe(24); + }); + + test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り25時間->猶予1日として計算されるはずである + createUser({ lastActiveDate: subDays(addHours(baseDate, 1), 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(1); + expect(result.remainingTime.asHours).toBe(25); + }); + + test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り23時間->猶予0日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(0); + expect(result.remainingTime.asHours).toBe(23); + }); + + test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限ちょうど->猶予0日として計算されるはずである + createUser({ lastActiveDate: subDays(baseDate, 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(0); + expect(result.remainingTime.asHours).toBe(0); + }); + + test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限より1時間超過->猶予-1日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1, user2]); + expect(result.remainingTime.asDays).toBe(-1); + expect(result.remainingTime.asHours).toBe(-1); + }); + + test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 10) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限より1時間超過->猶予-1日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1, user2]); + expect(result.remainingTime.asDays).toBe(-2); + expect(result.remainingTime.asHours).toBe(-25); + }); + }); + + describe('notifyInactiveModeratorsWarning', () => { + test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => { + const [user1, user2, user3, user4, root] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + createUser({}, { email: 'user2@example.com', emailVerified: false }), + createUser({}, { email: null, emailVerified: false }), + createUser({}, { email: 'user4@example.com', emailVerified: true }), + createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1, user2, user3, root]); + await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 }); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(2); + expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com'); + expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com'); + }); + + test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => { + const [user1] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1]); + await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 }); + + expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2); + }); + }); + + describe('notifyChangeToInvitationOnly', () => { + test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => { + const [user1, user2, user3, user4, root] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + createUser({}, { email: 'user2@example.com', emailVerified: false }), + createUser({}, { email: null, emailVerified: false }), + createUser({}, { email: 'user4@example.com', emailVerified: true }), + createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1, user2, user3, root]); + await service.notifyChangeToInvitationOnly(); + + expect(announcementService.create).toHaveBeenCalledTimes(4); + expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id); + expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id); + expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id); + expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(2); + expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com'); + expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com'); + }); + + test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => { + const [user1] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1]); + await service.notifyChangeToInvitationOnly(); + + expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2); + }); + }); +}); diff --git a/packages/frontend-embed/src/components/EmLoading.vue b/packages/frontend-embed/src/components/EmLoading.vue index 49d8ace37b..47d797606b 100644 --- a/packages/frontend-embed/src/components/EmLoading.vue +++ b/packages/frontend-embed/src/components/EmLoading.vue @@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{ --size: 38px; &.colored { - color: var(--accent); + color: var(--MI_THEME-accent); } &.inline { diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue index 435da238a4..cf4a4c53b5 100644 --- a/packages/frontend-embed/src/components/EmMediaBanner.vue +++ b/packages/frontend-embed/src/components/EmMediaBanner.vue @@ -31,17 +31,17 @@ defineProps<{ display: flex; align-items: center; width: 100%; - padding: var(--margin); + padding: var(--MI-margin); margin-top: 4px; - border: 1px solid var(--inputBorder); - border-radius: var(--radius); - background-color: var(--panel); + border: 1px solid var(--MI_THEME-inputBorder); + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); transition: background-color .1s, border-color .1s; &:hover { text-decoration: none; - border-color: var(--inputBorderHover); - background-color: var(--buttonHoverBg); + border-color: var(--MI_THEME-inputBorderHover); + background-color: var(--MI_THEME-buttonHoverBg); } } diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue index 470352469b..d711020a74 100644 --- a/packages/frontend-embed/src/components/EmMediaImage.vue +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
GIF
ALT
-
+
@@ -94,8 +94,8 @@ async function onclick(ev: MouseEvent) { display: block; position: absolute; border-radius: 6px; - background-color: var(--fg); - color: var(--accentLighten); + background-color: var(--MI_THEME-fg); + color: var(--MI_THEME-accentLighten); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -114,19 +114,19 @@ async function onclick(ev: MouseEvent) { .visible { position: relative; - //box-shadow: 0 0 0 1px var(--divider) inset; - background: var(--bg); + //box-shadow: 0 0 0 1px var(--MI_THEME-divider) inset; + background: var(--MI_THEME-bg); background-size: 16px 16px; } html[data-color-scheme=dark] .visible { --c: rgb(255 255 255 / 2%); - background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); } html[data-color-scheme=light] .visible { --c: rgb(0 0 0 / 2%); - background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); } .imageContainer { @@ -150,10 +150,10 @@ html[data-color-scheme=light] .visible { } .indicator { - /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--accentLighten); + color: var(--MI_THEME-accentLighten); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend-embed/src/components/EmMediaVideo.vue b/packages/frontend-embed/src/components/EmMediaVideo.vue index ce751f9acd..e2779bdee4 100644 --- a/packages/frontend-embed/src/components/EmMediaVideo.vue +++ b/packages/frontend-embed/src/components/EmMediaVideo.vue @@ -29,9 +29,9 @@ defineProps<{ width: 100%; height: auto; aspect-ratio: 16 / 9; - padding: var(--margin); - border: 1px solid var(--divider); - border-radius: var(--radius); + padding: var(--MI-margin); + border: 1px solid var(--MI_THEME-divider); + border-radius: var(--MI-radius); background-color: #000; &:hover { @@ -49,7 +49,7 @@ defineProps<{ } .videoOverlayPlayButton { - background: var(--accent); + background: var(--MI_THEME-accent); color: #fff; padding: 1rem; border-radius: 99rem; diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue index a631783507..a71364237d 100644 --- a/packages/frontend-embed/src/components/EmMention.vue +++ b/packages/frontend-embed/src/components/EmMention.vue @@ -27,7 +27,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us const url = `/${canonical}`; -const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--mention')); +const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-mention')); bg.setAlpha(0.1); const bgCss = bg.toRgbString(); @@ -37,7 +37,7 @@ const bgCss = bg.toRgbString(); display: inline-block; padding: 4px 8px 4px 4px; border-radius: 999px; - color: var(--mention); + color: var(--MI_THEME-mention); } .host { diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts index 59f0d495e6..cae2feb8fb 100644 --- a/packages/frontend-embed/src/components/EmMfm.ts +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -26,8 +26,8 @@ const QUOTE_STYLE = ` display: block; margin: 8px; padding: 6px 0 6px 12px; -color: var(--fg); -border-left: solid 3px var(--fg); +color: var(--MI_THEME-fg); +border-left: solid 3px var(--MI_THEME-fg); opacity: 0.7; `.split('\n').join(' '); @@ -251,7 +251,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext .collapsedLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } .collapsedLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -424,13 +418,13 @@ const isDeleted = ref(false); } .replyIcon { - color: var(--accent); + color: var(--MI_THEME-accent); margin-right: 0.5em; } .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); + border: solid 0.5px var(--MI_THEME-divider); + border-radius: var(--MI-radius); padding: 12px; margin-top: 8px; } @@ -449,7 +443,7 @@ const isDeleted = ref(false); .quoteNote { padding: 16px; - border: dashed 1px var(--renote); + border: dashed 1px var(--MI_THEME-renote); border-radius: 8px; overflow: clip; } @@ -473,7 +467,7 @@ const isDeleted = ref(false); } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -550,7 +544,7 @@ const isDeleted = ref(false); margin: 0 10px 0 0; width: 46px; height: 46px; - top: calc(14px + var(--stickyTop, 0px)); + top: calc(14px + var(--MI-stickyTop, 0px)); } } diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index 360de31864..b39b47c065 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -195,7 +195,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); padding: 16px 32px 8px 32px; line-height: 28px; white-space: pre; - color: var(--renote); + color: var(--MI_THEME-renote); } .renoteAvatar { @@ -281,7 +281,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); padding: 4px 6px; font-size: 80%; line-height: 1; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); border-radius: 4px; } @@ -323,14 +323,14 @@ const collapsed = ref(appearNote.value.cw == null && isLong); } .noteReplyTarget { - color: var(--accent); + color: var(--MI_THEME-accent); margin-right: 0.5em; } .rn { margin-left: 4px; font-style: oblique; - color: var(--renote); + color: var(--MI_THEME-renote); } .reactionOmitted { @@ -350,7 +350,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); .quoteNote { padding: 16px; - border: dashed 1px var(--renote); + border: dashed 1px var(--MI_THEME-renote); border-radius: 8px; overflow: clip; } @@ -364,12 +364,12 @@ const collapsed = ref(appearNote.value.cw == null && isLong); width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -390,16 +390,16 @@ const collapsed = ref(appearNote.value.cw == null && isLong); z-index: 2; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); + background: linear-gradient(0deg, var(--MI_THEME-panel), var(--MI_THEME-X15)); &:hover > .collapsedLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } .collapsedLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -422,7 +422,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -438,7 +438,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); opacity: 0.7; &.reacted { - color: var(--accent); + color: var(--MI_THEME-accent); } } diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue index 7d0b9bacad..85b4aac071 100644 --- a/packages/frontend-embed/src/components/EmNoteHeader.vue +++ b/packages/frontend-embed/src/components/EmNoteHeader.vue @@ -72,7 +72,7 @@ defineProps<{ margin: 0 .5em 0 0; padding: 1px 6px; font-size: 80%; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); border-radius: 3px; } diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue index 704a876e59..b9aaf3fa4a 100644 --- a/packages/frontend-embed/src/components/EmNoteSimple.vue +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -53,7 +53,7 @@ const showContent = ref(false); height: 34px; border-radius: 8px; position: sticky !important; - top: calc(16px + var(--stickyTop, 0px)); + top: calc(16px + var(--MI-stickyTop, 0px)); left: 0; } diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index f60aea3e7e..59be8608e0 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -123,7 +123,7 @@ if (props.detail) { } .reply, .more { - border-left: solid 0.5px var(--divider); + border-left: solid 0.5px var(--MI_THEME-divider); margin-top: 10px; } @@ -144,7 +144,7 @@ if (props.detail) { .muted { text-align: center; padding: 8px !important; - border: 1px solid var(--divider); + border: 1px solid var(--MI_THEME-divider); margin: 8px 8px 0 8px; border-radius: 8px; } diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue index 3418d97f77..4211261e19 100644 --- a/packages/frontend-embed/src/components/EmNotes.vue +++ b/packages/frontend-embed/src/components/EmNotes.vue @@ -43,10 +43,10 @@ defineExpose({ diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue index a2b1203449..d197e094c6 100644 --- a/packages/frontend-embed/src/components/EmPoll.vue +++ b/packages/frontend-embed/src/components/EmPoll.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
  • - + ({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }}) @@ -52,8 +52,8 @@ const total = computed(() => sum(props.poll.choices.map(x => x.votes))); position: relative; margin: 4px 0; padding: 4px; - //border: solid 0.5px var(--divider); - background: var(--accentedBg); + //border: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-accentedBg); border-radius: 4px; overflow: clip; } @@ -63,8 +63,8 @@ const total = computed(() => sum(props.poll.choices.map(x => x.votes))); top: 0; left: 0; height: 100%; - background: var(--accent); - background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); + background: var(--MI_THEME-accent); + background: linear-gradient(90deg,var(--MI_THEME-buttonGradateA),var(--MI_THEME-buttonGradateB)); transition: width 1s ease; } @@ -72,11 +72,11 @@ const total = computed(() => sum(props.poll.choices.map(x => x.votes))); position: relative; display: inline-block; padding: 3px 5px; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 3px; } .info { - color: var(--fg); + color: var(--MI_THEME-fg); } diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue index 2e43eb8d17..2ebff489fd 100644 --- a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue +++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue @@ -38,7 +38,7 @@ const props = defineProps<{ justify-content: center; &.canToggle { - background: var(--buttonBg); + background: var(--MI_THEME-buttonBg); &:hover { background: rgba(0, 0, 0, 0.1); @@ -72,12 +72,12 @@ const props = defineProps<{ } &.reacted, &.reacted:hover { - background: var(--accentedBg); - color: var(--accent); - box-shadow: 0 0 0 1px var(--accent) inset; + background: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + box-shadow: 0 0 0 1px var(--MI_THEME-accent) inset; > .count { - color: var(--accent); + color: var(--MI_THEME-accent); } > .icon { diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue index db2666a45f..61815ddfd8 100644 --- a/packages/frontend-embed/src/components/EmSubNoteContent.vue +++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue @@ -65,11 +65,11 @@ const collapsed = ref(isLong); left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); > .fadeLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -78,7 +78,7 @@ const collapsed = ref(isLong); &:hover { > .fadeLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } } @@ -87,25 +87,25 @@ const collapsed = ref(isLong); .reply { margin-right: 6px; - color: var(--accent); + color: var(--MI_THEME-accent); } .rp { margin-left: 4px; font-style: oblique; - color: var(--renote); + color: var(--MI_THEME-renote); } .showLess { width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue index c3986f7d70..7902e18483 100644 --- a/packages/frontend-embed/src/components/EmTime.vue +++ b/packages/frontend-embed/src/components/EmTime.vue @@ -98,10 +98,10 @@ if (!invalid && props.origin === null && (props.mode === 'relative' || props.mod diff --git a/packages/frontend-embed/src/components/EmTimelineContainer.vue b/packages/frontend-embed/src/components/EmTimelineContainer.vue index 6c30b1102d..60fd67ced9 100644 --- a/packages/frontend-embed/src/components/EmTimelineContainer.vue +++ b/packages/frontend-embed/src/components/EmTimelineContainer.vue @@ -20,7 +20,7 @@ withDefaults(defineProps<{ diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue index b481b3ebe5..4b00ae7c2d 100644 --- a/packages/frontend-embed/src/pages/tag.vue +++ b/packages/frontend-embed/src/pages/tag.vue @@ -83,7 +83,7 @@ function top(ev: MouseEvent) { display: flex; min-width: 0; align-items: center; - gap: var(--margin); + gap: var(--MI-margin); overflow: hidden; .headerClipIconRoot { @@ -93,8 +93,8 @@ function top(ev: MouseEvent) { line-height: 32px; font-size: 14px; text-align: center; - background-color: var(--accentedBg); - color: var(--accent); + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); border-radius: 50%; } diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue index 85e6f52d50..348b1a7622 100644 --- a/packages/frontend-embed/src/pages/user-timeline.vue +++ b/packages/frontend-embed/src/pages/user-timeline.vue @@ -117,7 +117,7 @@ function top(ev: MouseEvent) { display: flex; min-width: 0; align-items: center; - gap: var(--margin); + gap: var(--MI-margin); overflow: hidden; .avatarLink { diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss index 02008ddbd0..2e43cfd20a 100644 --- a/packages/frontend-embed/src/style.scss +++ b/packages/frontend-embed/src/style.scss @@ -7,18 +7,18 @@ */ :root { - --radius: 12px; - --marginFull: 14px; - --marginHalf: 10px; + --MI-radius: 12px; + --MI-marginFull: 14px; + --MI-marginHalf: 10px; - --margin: var(--marginFull); + --MI-margin: var(--MI-marginFull); } html { background-color: transparent; color-scheme: light dark; - color: var(--fg); - accent-color: var(--accent); + color: var(--MI_THEME-fg); + accent-color: var(--MI_THEME-accent); overflow: clip; overflow-wrap: break-word; font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; @@ -29,7 +29,7 @@ html { -webkit-text-size-adjust: 100%; &, * { - scrollbar-color: var(--scrollbarHandle) transparent; + scrollbar-color: var(--MI_THEME-scrollbarHandle) transparent; scrollbar-width: thin; &::-webkit-scrollbar { @@ -42,14 +42,14 @@ html { } &::-webkit-scrollbar-thumb { - background: var(--scrollbarHandle); + background: var(--MI_THEME-scrollbarHandle); &:hover { - background: var(--scrollbarHandleHover); + background: var(--MI_THEME-scrollbarHandleHover); } &:active { - background: var(--accent); + background: var(--MI_THEME-accent); } } } @@ -93,7 +93,7 @@ rt { } :focus-visible { - outline: var(--focus) solid 2px; + outline: var(--MI_THEME-focus) solid 2px; outline-offset: -2px; &:hover { @@ -151,38 +151,38 @@ rt { ._buttonGray { @extend ._button; - background: var(--buttonBg); + background: var(--MI_THEME-buttonBg); &:not(:disabled):hover { - background: var(--buttonHoverBg); + background: var(--MI_THEME-buttonHoverBg); } } ._buttonPrimary { @extend ._button; - color: var(--fgOnAccent); - background: var(--accent); + color: var(--MI_THEME-fgOnAccent); + background: var(--MI_THEME-accent); &:not(:disabled):hover { - background: hsl(from var(--accent) h s calc(l + 5)); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); } &:not(:disabled):active { - background: hsl(from var(--accent) h s calc(l - 5)); + background: hsl(from var(--MI_THEME-accent) h s calc(l - 5)); } } ._buttonGradate { @extend ._buttonPrimary; - color: var(--fgOnAccent); - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + color: var(--MI_THEME-fgOnAccent); + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } } @@ -199,13 +199,13 @@ rt { } ._help { - color: var(--accent); + color: var(--MI_THEME-accent); cursor: help; } ._textButton { @extend ._button; - color: var(--accent); + color: var(--MI_THEME-accent); &:focus-visible { outline-offset: 2px; @@ -217,13 +217,13 @@ rt { } ._panel { - background: var(--panel); - border-radius: var(--radius); + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); overflow: clip; } ._margin { - margin: var(--margin) 0; + margin: var(--MI-margin) 0; } ._gaps_m { @@ -241,7 +241,7 @@ rt { ._gaps { display: flex; flex-direction: column; - gap: var(--margin); + gap: var(--MI-margin); } ._buttons { @@ -263,24 +263,24 @@ rt { padding: 10px; box-sizing: border-box; text-align: center; - border: solid 0.5px var(--divider); - border-radius: var(--radius); + border: solid 0.5px var(--MI_THEME-divider); + border-radius: var(--MI-radius); &:active { - border-color: var(--accent); + border-color: var(--MI_THEME-accent); } } ._popup { - background: var(--popup); - border-radius: var(--radius); + background: var(--MI_THEME-popup); + border-radius: var(--MI-radius); contain: content; } ._acrylic { - background: var(--acrylicPanel); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + background: var(--MI_THEME-acrylicPanel); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); } ._fullinfo { @@ -296,7 +296,7 @@ rt { } ._link { - color: var(--link); + color: var(--MI_THEME-link); } ._caption { diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts index 23e70cd0d3..4664ad4880 100644 --- a/packages/frontend-embed/src/theme.ts +++ b/packages/frontend-embed/src/theme.ts @@ -61,7 +61,7 @@ export function applyTheme(theme: Theme, persist = true) { } for (const [k, v] of Object.entries(props)) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); } // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照 diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue index 8da5f46a96..4ba5968a91 100644 --- a/packages/frontend-embed/src/ui.vue +++ b/packages/frontend-embed/src/ui.vue @@ -88,14 +88,14 @@ onUnmounted(() => { diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index b50a7fea5c..e52ab5ccad 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -106,7 +106,7 @@ const containerStyle = computed(() => { const border = isBordered ? { borderWidth: c.borderWidth ?? '1px', - borderColor: c.borderColor ?? 'var(--divider)', + borderColor: c.borderColor ?? 'var(--MI_THEME-divider)', borderStyle: c.borderStyle ?? 'solid', } : undefined; @@ -165,7 +165,7 @@ function openPostForm() { } .postForm { - background: var(--bg); + background: var(--MI_THEME-bg); border-radius: 8px; } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index f547991369..0ea4566d4e 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -407,16 +407,16 @@ onBeforeUnmount(() => { text-overflow: ellipsis; &:hover { - background: var(--X3); + background: var(--MI_THEME-X3); } &[data-selected='true'] { - background: var(--accent); + background: var(--MI_THEME-accent); color: #fff !important; } &:active { - background: var(--accentDarken); + background: var(--MI_THEME-accentDarken); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 1156b3f2b8..311facb4aa 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -129,7 +129,7 @@ function onMousedown(evt: MouseEvent): void { font-size: 95%; box-shadow: none; text-decoration: none; - background: var(--buttonBg); + background: var(--MI_THEME-buttonBg); border-radius: 5px; overflow: clip; box-sizing: border-box; @@ -140,11 +140,11 @@ function onMousedown(evt: MouseEvent): void { } &:not(:disabled):hover { - background: var(--buttonHoverBg); + background: var(--MI_THEME-buttonHoverBg); } &:not(:disabled):active { - background: var(--buttonHoverBg); + background: var(--MI_THEME-buttonHoverBg); } &.small { @@ -167,15 +167,15 @@ function onMousedown(evt: MouseEvent): void { &.primary { font-weight: bold; - color: var(--fgOnAccent) !important; - background: var(--accent); + color: var(--MI_THEME-fgOnAccent) !important; + background: var(--MI_THEME-accent); &:not(:disabled):hover { - background: hsl(from var(--accent) h s calc(l + 5)); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); } &:not(:disabled):active { - background: hsl(from var(--accent) h s calc(l + 5)); + background: hsl(from var(--MI_THEME-accent) h s calc(l + 5)); } } @@ -216,15 +216,15 @@ function onMousedown(evt: MouseEvent): void { &.gradate { font-weight: bold; - color: var(--fgOnAccent) !important; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + color: var(--MI_THEME-fgOnAccent) !important; + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); &:not(:disabled):hover { - background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } &:not(:disabled):active { - background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5))); } } diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c5b6e0caed..82fc89e51c 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -10,6 +10,17 @@ SPDX-License-Identifier: AGPL-3.0-only
    +
    + +
    +
    Test captcha passed!
    +
    +
    +
    Type "ai-chan-kawaii" to pass captcha
    + + +
    +
    @@ -29,7 +40,7 @@ export type Captcha = { getResponse(id: string): string; }; -export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -54,12 +65,16 @@ const available = ref(false); const captchaEl = shallowRef(); +const testcaptchaInput = ref(''); +const testcaptchaPassed = ref(false); + const variable = computed(() => { switch (props.provider) { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; case 'turnstile': return 'turnstile'; case 'mcaptcha': return 'mcaptcha'; + case 'testcaptcha': return 'testcaptcha'; } }); @@ -71,6 +86,7 @@ const src = computed(() => { case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; case 'mcaptcha': return null; + case 'testcaptcha': return null; } }); @@ -78,7 +94,7 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed(() => window[variable.value] || {} as unknown as Captcha); -if (loaded || props.provider === 'mcaptcha') { +if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { @@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') { function reset() { if (captcha.value.reset) captcha.value.reset(); + testcaptchaPassed.value = false; + testcaptchaInput.value = ''; } async function requestRender() { @@ -127,6 +145,12 @@ function onReceivedMessage(message: MessageEvent) { } } +function testcaptchaSubmit() { + testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii'; + callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined); + if (!testcaptchaPassed.value) testcaptchaInput.value = ''; +} + onMounted(() => { if (available.value) { window.addEventListener('message', onReceivedMessage); diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 35dc3ad4bf..d4e4f6179a 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -68,9 +68,9 @@ async function onClick() { position: relative; display: inline-block; font-weight: bold; - color: var(--accent); + color: var(--MI_THEME-accent); background: transparent; - border: solid 1px var(--accent); + border: solid 1px var(--MI_THEME-accent); padding: 0; height: 31px; font-size: 16px; @@ -99,17 +99,17 @@ async function onClick() { } &.active { - color: var(--fgOnAccent); - background: var(--accent); + color: var(--MI_THEME-fgOnAccent); + background: var(--MI_THEME-accent); &:hover { - background: var(--accentLighten); - border-color: var(--accentLighten); + background: var(--MI_THEME-accentLighten); + border-color: var(--MI_THEME-accentLighten); } &:active { - background: var(--accentDarken); - border-color: var(--accentDarken); + background: var(--MI_THEME-accentDarken); + border-color: var(--MI_THEME-accentDarken); } } diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index 3c0874a1eb..99580df5e2 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -100,7 +100,7 @@ const bannerStyle = computed(() => { height: 100%; border-radius: inherit; pointer-events: none; - box-shadow: inset 0 0 0 2px var(--focus); + box-shadow: inset 0 0 0 2px var(--MI_THEME-focus); } } @@ -117,7 +117,7 @@ const bannerStyle = computed(() => { left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); } > .name { @@ -148,7 +148,7 @@ const bannerStyle = computed(() => { bottom: 16px; left: 16px; background: rgba(0, 0, 0, 0.7); - color: var(--warn); + color: var(--MI_THEME-warn); border-radius: 6px; font-weight: bold; font-size: 1em; @@ -167,7 +167,7 @@ const bannerStyle = computed(() => { > footer { padding: 12px 16px; - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); > span { opacity: 0.7; @@ -213,8 +213,8 @@ const bannerStyle = computed(() => { top: 0; right: 0; transform: translate(25%, -25%); - background-color: var(--accent); - border: solid var(--bg) 4px; + background-color: var(--MI_THEME-accent); + border: solid var(--MI_THEME-bg) 4px; border-radius: 100%; width: 1.5rem; height: 1.5rem; diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 57d325b11a..d05f4921f6 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -863,8 +863,8 @@ onMounted(() => { left: 0; width: 100%; height: 100%; - -webkit-backdrop-filter: var(--blur, blur(12px)); - backdrop-filter: var(--blur, blur(12px)); + -webkit-backdrop-filter: var(--MI-blur, blur(12px)); + backdrop-filter: var(--MI-blur, blur(12px)); display: flex; justify-content: center; align-items: center; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index 6eb2009784..574cde9da4 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -53,11 +53,11 @@ defineExpose({ > .item { font-size: 85%; padding: 4px 12px 4px 8px; - border: solid 1px var(--divider); + border: solid 1px var(--MI_THEME-divider); border-radius: 999px; &:hover { - border-color: var(--inputBorderHover); + border-color: var(--MI_THEME-inputBorderHover); } &.disabled { diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index dd550733cb..5b09ec90dd 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -49,13 +49,13 @@ const remaining = computed(() => { outline: none; .root { - box-shadow: inset 0 0 0 2px var(--focus); + box-shadow: inset 0 0 0 2px var(--MI_THEME-focus); } } &:hover { text-decoration: none; - color: var(--accent); + color: var(--MI_THEME-accent); } } @@ -65,7 +65,7 @@ const remaining = computed(() => { .divider { height: 1px; - background: var(--divider); + background: var(--MI_THEME-divider); } .description { diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index c0e7df5dac..0d7a67eaec 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -77,7 +77,7 @@ watch(() => props.lang, (to) => { margin: .5em 0; overflow: auto; border-radius: 8px; - border: 1px solid var(--divider); + border: 1px solid var(--MI_THEME-divider); font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; color: var(--shiki-fallback); diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index 716dd92678..cb82bfd98b 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -71,7 +71,7 @@ function copy() { .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - background: var(--bg); + background: var(--MI_THEME-bg); padding: 1em; margin: .5em 0; overflow: auto; @@ -94,8 +94,8 @@ function copy() { border-radius: 8px; padding: 24px; margin-top: 4px; - color: var(--fg); - background: var(--bg); + color: var(--MI_THEME-fg); + background: var(--MI_THEME-bg); } .codePlaceholderContainer { diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index afd9132a12..5bf2301e72 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -140,7 +140,7 @@ watch(v, newValue => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); &:empty { display: none; @@ -160,17 +160,17 @@ watch(v, newValue => { margin: 0; border-radius: 6px; padding: 0; - color: var(--fg); - border: solid 1px var(--panel); + color: var(--MI_THEME-fg); + border: solid 1px var(--MI_THEME-panel); transition: border-color 0.1s ease-out; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; &:hover { - border-color: var(--inputBorderHover) !important; + border-color: var(--MI_THEME-inputBorderHover) !important; } } .focused.codeEditorRoot { - border-color: var(--accent) !important; + border-color: var(--MI_THEME-accent) !important; border-radius: 6px; } @@ -196,7 +196,7 @@ watch(v, newValue => { resize: none; text-align: left; color: transparent; - caret-color: var(--fg); + caret-color: var(--MI_THEME-fg); background-color: transparent; border: 0; border-radius: 6px; @@ -211,6 +211,6 @@ watch(v, newValue => { } .textarea::selection { - color: var(--bg); + color: var(--MI_THEME-bg); } diff --git a/packages/frontend/src/components/MkCodeInline.vue b/packages/frontend/src/components/MkCodeInline.vue index 6add80d1bc..04b6e54108 100644 --- a/packages/frontend/src/components/MkCodeInline.vue +++ b/packages/frontend/src/components/MkCodeInline.vue @@ -18,7 +18,7 @@ const props = defineProps<{ display: inline-block; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; overflow-wrap: anywhere; - background: var(--bg); + background: var(--MI_THEME-bg); padding: .1em; border-radius: .3em; } diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index f5c580789b..55a32664de 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -60,7 +60,7 @@ const onInput = () => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); &:empty { display: none; @@ -72,8 +72,8 @@ const onInput = () => { &.focused { > .inputCore { - border-color: var(--accent) !important; - //box-shadow: 0 0 0 4px var(--focus); + border-color: var(--MI_THEME-accent) !important; + //box-shadow: 0 0 0 4px var(--MI_THEME-focus); } } @@ -98,9 +98,9 @@ const onInput = () => { font: inherit; font-weight: normal; font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--panel); + color: var(--MI_THEME-fg); + background: var(--MI_THEME-panel); + border: solid 1px var(--MI_THEME-panel); border-radius: 6px; outline: none; box-shadow: none; @@ -108,7 +108,7 @@ const onInput = () => { transition: border-color 0.1s ease-out; &:hover { - border-color: var(--inputBorderHover) !important; + border-color: var(--MI_THEME-inputBorderHover) !important; } } diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 8ad653a0bf..8ab01d7db8 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -165,11 +165,11 @@ onUnmounted(() => { .header { position: sticky; - top: var(--stickyTop, 0px); + top: var(--MI-stickyTop, 0px); left: 0; - color: var(--panelHeaderFg); - background: var(--panelHeaderBg); - border-bottom: solid 0.5px var(--panelHeaderDivider); + color: var(--MI_THEME-panelHeaderFg); + background: var(--MI_THEME-panelHeaderBg); + border-bottom: solid 0.5px var(--MI_THEME-panelHeaderDivider); z-index: 2; line-height: 1.4em; } @@ -201,7 +201,7 @@ onUnmounted(() => { } .content { - --stickyTop: 0px; + --MI-stickyTop: 0px; &.omitted { position: relative; @@ -216,11 +216,11 @@ onUnmounted(() => { left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); > .fadeLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -229,7 +229,7 @@ onUnmounted(() => { &:hover { > .fadeLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } } diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 2e1e92cbdf..c2a1aaf29a 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -125,7 +125,7 @@ onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); const selection = cropper.getCropperSelection()!; - selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); selection.aspectRatio = props.aspectRatio; selection.initialAspectRatio = props.aspectRatio; selection.outlined = true; @@ -170,8 +170,8 @@ onMounted(() => { display: flex; align-items: center; justify-content: center; - -webkit-backdrop-filter: var(--blur, blur(10px)); - backdrop-filter: var(--blur, blur(10px)); + -webkit-backdrop-filter: var(--MI-blur, blur(10px)); + backdrop-filter: var(--MI-blur, blur(10px)); background: rgba(0, 0, 0, 0.5); } diff --git a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue index c7f1288729..949adc6a8e 100644 --- a/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue +++ b/packages/frontend/src/components/MkCustomEmojiDetailedDialog.vue @@ -85,8 +85,8 @@ function cancel() { .emojiImgWrapper { max-width: 100%; height: 40cqh; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--X5) 8px, var(--X5) 14px); - border-radius: var(--radius); + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-X5) 8px, var(--MI_THEME-X5) 14px); + border-radius: var(--MI-radius); margin: auto; overflow-y: hidden; } @@ -101,8 +101,8 @@ function cancel() { display: inline-block; word-break: break-all; padding: 3px 10px; - background-color: var(--X5); - border: solid 1px var(--divider); - border-radius: var(--radius); + background-color: var(--MI_THEME-X5); + border: solid 1px var(--MI_THEME-divider); + border-radius: var(--MI-radius); } diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 4b94bef4b6..f04e5cf7c6 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -9,6 +9,7 @@ import MkAd from '@/components/global/MkAd.vue'; import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { instance } from '@/instance.js'; import { defaultStore } from '@/store.js'; import { MisskeyEntity } from '@/types/date-separated-list.js'; @@ -99,11 +100,13 @@ export default defineComponent({ return [el, separator]; } else { - if (props.ad && item._shouldInsertAd_) { - return [h(MkAd, { + if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) { + return [h('div', { key: item.id + ':ad', + class: $style['ad-wrapper'], + }, [h(MkAd, { prefer: ['horizontal', 'horizontal-big'], - }), el]; + })]), el]; } else { return el; } @@ -182,7 +185,7 @@ export default defineComponent({ } &:not(.date-separated-list-nogap) > *:not(:last-child) { - margin-bottom: var(--margin); + margin-bottom: var(--MI-margin); } } @@ -194,7 +197,7 @@ export default defineComponent({ box-shadow: none; &:not(:last-child) { - border-bottom: solid 0.5px var(--divider); + border-bottom: solid 0.5px var(--MI_THEME-divider); } } } @@ -235,7 +238,7 @@ export default defineComponent({ line-height: 32px; text-align: center; font-size: 12px; - color: var(--dateLabelFg); + color: var(--MI_THEME-dateLabelFg); } .date-1 { @@ -253,5 +256,11 @@ export default defineComponent({ .date-2-icon { margin-left: 8px; } + +.ad-wrapper { + padding: 8px; + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px); +} diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 16cf5b1b75..22130d4fab 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -184,7 +184,7 @@ function onInputKeydown(evt: KeyboardEvent) { max-width: 480px; box-sizing: border-box; text-align: center; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 16px; } @@ -206,15 +206,15 @@ function onInputKeydown(evt: KeyboardEvent) { } .type_success { - color: var(--success); + color: var(--MI_THEME-success); } .type_error { - color: var(--error); + color: var(--MI_THEME-error); } .type_warning { - color: var(--warn); + color: var(--MI_THEME-warn); } .title { diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue index e4e3af99e4..f72f091383 100644 --- a/packages/frontend/src/components/MkDivider.vue +++ b/packages/frontend/src/components/MkDivider.vue @@ -27,6 +27,6 @@ defineProps<{ diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 098be07a8c..0e0da64750 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -65,12 +65,12 @@ function neverShow() { .root { position: fixed; z-index: v-bind(zIndex); - bottom: var(--margin); + bottom: var(--MI-margin); left: 0; right: 0; margin: auto; box-sizing: border-box; - width: calc(100% - (var(--margin) * 2)); + width: calc(100% - (var(--MI-margin) * 2)); max-width: 500px; display: flex; } @@ -79,7 +79,7 @@ function neverShow() { text-align: center; padding-top: 25px; width: 100px; - color: var(--accent); + color: var(--MI_THEME-accent); } @media (max-width: 500px) { .icon { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 90284890a5..e45c3bd9ce 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -148,14 +148,14 @@ function onDragend() { } &.isSelected { - background: var(--accent); + background: var(--MI_THEME-accent); &:hover { - background: var(--accentLighten); + background: var(--MI_THEME-accentLighten); } &:active { - background: var(--accentDarken); + background: var(--MI_THEME-accentDarken); } > .label { @@ -244,7 +244,7 @@ function onDragend() { font-size: 0.8em; text-align: center; word-break: break-all; - color: var(--fg); + color: var(--MI_THEME-fg); overflow: hidden; } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 92b3a23662..44e3b59ade 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -36,13 +36,13 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -144,15 +144,15 @@ onMounted(() => { width: 100%; box-sizing: border-box; padding: 9px 12px 9px 12px; - background: var(--folderHeaderBg); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + background: var(--MI_THEME-folderHeaderBg); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); border-radius: 6px; transition: border-radius 0.3s; &:hover { text-decoration: none; - background: var(--folderHeaderHoverBg); + background: var(--MI_THEME-folderHeaderHoverBg); } &:focus-within { @@ -160,8 +160,8 @@ onMounted(() => { } &.active { - color: var(--accent); - background: var(--folderHeaderHoverBg); + color: var(--MI_THEME-accent); + background: var(--MI_THEME-folderHeaderHoverBg); } &.opened { @@ -175,7 +175,7 @@ onMounted(() => { } .headerLower { - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); font-size: .85em; padding-left: 4px; } @@ -209,13 +209,13 @@ onMounted(() => { } .headerTextSub { - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); font-size: .85em; } .headerRight { margin-left: auto; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); white-space: nowrap; } @@ -224,26 +224,26 @@ onMounted(() => { } .body { - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 0 0 6px 6px; container-type: inline-size; &.bgSame { - background: var(--bg); + background: var(--MI_THEME-bg); } } .footer { position: sticky !important; z-index: 1; - bottom: var(--stickyBottom, 0px); + bottom: var(--MI-stickyBottom, 0px); left: 0; padding: 12px; - background: var(--acrylicBg); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + background: var(--MI_THEME-acrylicBg); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, var(--panel) 5px, var(--panel) 10px); + background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, var(--MI_THEME-panel) 5px, var(--MI_THEME-panel) 10px); border-radius: 0 0 6px 6px; } diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index 0de52906ed..ccea7cd453 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -165,8 +165,8 @@ onBeforeUnmount(() => { position: relative; display: inline-block; font-weight: bold; - color: var(--fgOnWhite); - border: solid 1px var(--accent); + color: var(--MI_THEME-fgOnWhite); + border: solid 1px var(--MI_THEME-accent); padding: 0; height: 31px; font-size: 16px; @@ -201,17 +201,17 @@ onBeforeUnmount(() => { } &.active { - color: var(--fgOnAccent); - background: var(--accent); + color: var(--MI_THEME-fgOnAccent); + background: var(--MI_THEME-accent); &:hover { - background: var(--accentLighten); - border-color: var(--accentLighten); + background: var(--MI_THEME-accentLighten); + border-color: var(--MI_THEME-accentLighten); } &:active { - background: var(--accentDarken); - border-color: var(--accentDarken); + background: var(--MI_THEME-accentDarken); + border-color: var(--MI_THEME-accentDarken); } } diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index 9360594236..ecb6cf882b 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -66,6 +66,6 @@ function selectButton(ev: MouseEvent) { diff --git a/packages/frontend/src/components/MkFormFooter.vue b/packages/frontend/src/components/MkFormFooter.vue index 1e88d59d8e..f409f6ce50 100644 --- a/packages/frontend/src/components/MkFormFooter.vue +++ b/packages/frontend/src/components/MkFormFooter.vue @@ -36,7 +36,7 @@ const props = defineProps<{ } .text { - color: var(--warn); + color: var(--MI_THEME-warn); font-size: 90%; animation: modified-blink 2s infinite; } diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue index 09825487bf..8b1c56fca4 100644 --- a/packages/frontend/src/components/MkFukidashi.vue +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -39,8 +39,8 @@ withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue index 86ed8ba2cf..df7505b0c3 100644 --- a/packages/frontend/src/components/MkMediaRange.vue +++ b/packages/frontend/src/components/MkMediaRange.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -304,8 +320,8 @@ async function onSubmit(): Promise { padding: 16px; text-align: center; font-size: 26px; - background-color: var(--accentedBg); - color: var(--accent); + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); } .captcha { diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index 59a3651cd4..e2a06dd91f 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
    1. @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
      @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + {{ i18n.ts.basicNotesBeforeCreateAccount }} @@ -150,8 +150,8 @@ async function updateAgreeNote(v: boolean) { padding: 16px; text-align: center; font-size: 26px; - background-color: var(--accentedBg); - color: var(--accent); + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); } .rules { @@ -170,14 +170,14 @@ async function updateAgreeNote(v: boolean) { flex-shrink: 0; display: flex; position: sticky; - top: calc(var(--stickyTop, 0px) + 8px); + top: calc(var(--MI-stickyTop, 0px) + 8px); counter-increment: item; content: counter(item); width: 32px; height: 32px; line-height: 32px; - background-color: var(--accentedBg); - color: var(--accent); + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); font-size: 13px; font-weight: bold; align-items: center; diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 4cccd99492..f240e6dc46 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', res: Misskey.entities.SigninFlowResponse): void; + (ev: 'done', res: Misskey.entities.SignupResponse): void; (ev: 'closed'): void; }>(); @@ -55,7 +55,7 @@ const dialog = shallowRef>(); const isAcceptedServerRule = ref(false); -function onSignup(res: Misskey.entities.SigninFlowResponse) { +function onSignup(res: Misskey.entities.SignupResponse) { emit('done', res); dialog.value?.close(); } diff --git a/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue b/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue index 1845b01b69..4c197ed43e 100644 --- a/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue +++ b/packages/frontend/src/components/MkSourceCodeAvailablePopup.vue @@ -63,12 +63,12 @@ function close() { .root { position: fixed; z-index: v-bind(zIndex); - bottom: var(--margin); + bottom: var(--MI-margin); left: 0; right: 0; margin: auto; box-sizing: border-box; - width: calc(100% - (var(--margin) * 2)); + width: calc(100% - (var(--MI-margin) * 2)); max-width: 500px; display: flex; } @@ -77,7 +77,7 @@ function close() { text-align: center; padding-top: 25px; width: 100px; - color: var(--accent); + color: var(--MI_THEME-accent); } @media (max-width: 500px) { .icon { diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 3bbb163f0f..9e02884b8c 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -62,11 +62,11 @@ const collapsed = ref(isLong); left: 0; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); > .fadeLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -75,7 +75,7 @@ const collapsed = ref(isLong); &:hover { > .fadeLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } } @@ -84,25 +84,25 @@ const collapsed = ref(isLong); .reply { margin-right: 6px; - color: var(--accent); + color: var(--MI_THEME-accent); } .rp { margin-left: 4px; font-style: oblique; - color: var(--renote); + color: var(--MI_THEME-renote); } .showLess { width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 3746ffd8f3..6e7a875dec 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -43,7 +43,7 @@ defineProps<{ & + .group { margin-top: 16px; padding-top: 16px; - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); } > .title { @@ -64,7 +64,7 @@ defineProps<{ &:hover { text-decoration: none; - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } &:focus-visible { @@ -72,12 +72,12 @@ defineProps<{ } &.active { - color: var(--accent); - background: var(--accentedBg); + color: var(--MI_THEME-accent); + background: var(--MI_THEME-accentedBg); } &.danger { - color: var(--error); + color: var(--MI_THEME-error); } > .icon { @@ -128,10 +128,10 @@ defineProps<{ &:hover { text-decoration: none; background: none; - color: var(--accent); + color: var(--MI_THEME-accent); > .icon { - background: var(--accentedBg); + background: var(--MI_THEME-accentedBg); } } @@ -144,7 +144,7 @@ defineProps<{ width: 60px; height: 60px; aspect-ratio: 1; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 100%; } diff --git a/packages/frontend/src/components/MkSwitch.button.vue b/packages/frontend/src/components/MkSwitch.button.vue index 226908e221..fd8ed0992e 100644 --- a/packages/frontend/src/components/MkSwitch.button.vue +++ b/packages/frontend/src/components/MkSwitch.button.vue @@ -51,9 +51,9 @@ const toggle = () => { width: calc(var(--height) * 1.6); height: calc(var(--height) + 2px); // 枠線 outline: none; - background: var(--switchOffBg); + background: var(--MI_THEME-switchOffBg); background-clip: content-box; - border: solid 1px var(--switchOffBg); + border: solid 1px var(--MI_THEME-switchOffBg); border-radius: 999px; cursor: pointer; transition: inherit; @@ -61,8 +61,8 @@ const toggle = () => { } .buttonChecked { - background-color: var(--switchOnBg) !important; - border-color: var(--switchOnBg) !important; + background-color: var(--MI_THEME-switchOnBg) !important; + border-color: var(--MI_THEME-switchOnBg) !important; } .buttonDisabled { @@ -80,12 +80,12 @@ const toggle = () => { &:not(.knobChecked) { left: 3px; - background: var(--switchOffFg); + background: var(--MI_THEME-switchOffFg); } } .knobChecked { left: calc(calc(100% - var(--height)) + 3px); - background: var(--switchOnFg); + background: var(--MI_THEME-switchOnFg); } diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index a0994d9cc9..5e6029ee40 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -59,7 +59,7 @@ const toggle = () => { &:hover { > .button { - border-color: var(--inputBorderHover) !important; + border-color: var(--MI_THEME-inputBorderHover) !important; } } @@ -77,7 +77,7 @@ const toggle = () => { margin: 0; &:focus-visible ~ .toggle { - outline: 2px solid var(--focus); + outline: 2px solid var(--MI_THEME-focus); outline-offset: 2px; } } @@ -87,7 +87,7 @@ const toggle = () => { margin-top: 2px; display: block; transition: inherit; - color: var(--fg); + color: var(--MI_THEME-fg); } .label { @@ -99,7 +99,7 @@ const toggle = () => { .caption { margin: 8px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); font-size: 0.85em; &:empty { diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue index ec3b1c90ca..485d003f93 100644 --- a/packages/frontend/src/components/MkSystemWebhookEditor.vue +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -55,6 +55,18 @@ SPDX-License-Identifier: AGPL-3.0-only
      +
      + + + + +
      +
      + + + + +
      @@ -100,6 +112,8 @@ type EventType = { abuseReport: boolean; abuseReportResolved: boolean; userCreated: boolean; + inactiveModeratorsWarning: boolean; + inactiveModeratorsInvitationOnlyChanged: boolean; } const emit = defineEmits<{ @@ -123,6 +137,8 @@ const events = ref({ abuseReport: true, abuseReportResolved: true, userCreated: true, + inactiveModeratorsWarning: true, + inactiveModeratorsInvitationOnlyChanged: true, }); const isActive = ref(true); @@ -130,6 +146,8 @@ const disabledEvents = ref({ abuseReport: false, abuseReportResolved: false, userCreated: false, + inactiveModeratorsWarning: false, + inactiveModeratorsInvitationOnlyChanged: false, }); const disableSubmitButton = computed(() => { @@ -261,10 +279,10 @@ onMounted(async () => { bottom: 0; left: 0; padding: 12px; - border-top: solid 0.5px var(--divider); - background: var(--acrylicBg); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + border-top: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-acrylicBg); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); } .switchBox { @@ -289,6 +307,6 @@ onMounted(async () => { .description { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); } diff --git a/packages/frontend/src/components/MkTab.vue b/packages/frontend/src/components/MkTab.vue index f2d0c95013..f557ffa5dc 100644 --- a/packages/frontend/src/components/MkTab.vue +++ b/packages/frontend/src/components/MkTab.vue @@ -47,13 +47,13 @@ export default defineComponent({ } &.active { - color: var(--accent); - background: var(--accentedBg); + color: var(--MI_THEME-accent); + background: var(--MI_THEME-accentedBg); } &:not(.active):hover { - color: var(--fgHighlighted); - background: var(--panelHighlight); + color: var(--MI_THEME-fgHighlighted); + background: var(--MI_THEME-panelHighlight); } &:not(:first-child) { diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index 6b9c181597..87aa046963 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -33,7 +33,7 @@ watch(available, () => { try { window.TagCanvas.Start(idForCanvas, idForTags, { textColour: '#ffffff', - outlineColour: tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(), + outlineColour: tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(), outlineRadius: 10, initial: [-0.030, -0.010], frontSelect: true, diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index 59490c552a..d1a6e1ebbf 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -159,7 +159,7 @@ onUnmounted(() => { .caption { font-size: 0.85em; padding: 8px 0 0 0; - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); &:empty { display: none; @@ -179,9 +179,9 @@ onUnmounted(() => { font: inherit; font-weight: normal; font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--panel); + color: var(--MI_THEME-fg); + background: var(--MI_THEME-panel); + border: solid 1px var(--MI_THEME-panel); border-radius: 6px; outline: none; box-shadow: none; @@ -189,13 +189,13 @@ onUnmounted(() => { transition: border-color 0.1s ease-out; &:hover { - border-color: var(--inputBorderHover) !important; + border-color: var(--MI_THEME-inputBorderHover) !important; } } .focused { > .textarea { - border-color: var(--accent) !important; + border-color: var(--MI_THEME-accent) !important; } } @@ -226,7 +226,7 @@ onUnmounted(() => { .mfmPreview { padding: 12px; - border-radius: var(--radius); + border-radius: var(--MI-radius); box-sizing: border-box; min-height: 130px; pointer-events: none; diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index b32066c950..a7bc3f37f1 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -136,15 +136,15 @@ function enableAll(): void { .adminPermissions { margin: 8px -6px 0; padding: 24px 6px 6px; - border: 2px solid var(--error); - border-radius: calc(var(--radius) / 2); + border: 2px solid var(--MI_THEME-error); + border-radius: calc(var(--MI-radius) / 2); } .adminPermissionsHeader { margin: -34px 0 6px 12px; padding: 0 4px; width: fit-content; - color: var(--error); - background: var(--panel); + color: var(--MI_THEME-error); + background: var(--MI_THEME-panel); } diff --git a/packages/frontend/src/components/MkTooltip.vue b/packages/frontend/src/components/MkTooltip.vue index a3620aab68..10365d29b1 100644 --- a/packages/frontend/src/components/MkTooltip.vue +++ b/packages/frontend/src/components/MkTooltip.vue @@ -110,7 +110,7 @@ onUnmounted(() => { box-sizing: border-box; text-align: center; border-radius: 4px; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); pointer-events: none; transform-origin: center center; } diff --git a/packages/frontend/src/components/MkTutorialDialog.Note.vue b/packages/frontend/src/components/MkTutorialDialog.Note.vue index 2a26d22dc2..b26a01737e 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Note.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Note.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
      {{ i18n.ts._initialTutorial._reaction.description }}
      {{ i18n.ts._initialTutorial._reaction.letsTryReacting }}
      -
      {{ i18n.ts._initialTutorial.wellDone }} {{ i18n.ts._initialTutorial._reaction.reactNotification }}
      {{ i18n.ts._initialTutorial._reaction.reactDone }}
      +
      {{ i18n.ts._initialTutorial.wellDone }} {{ i18n.ts._initialTutorial._reaction.reactNotification }}
      {{ i18n.ts._initialTutorial._reaction.reactDone }}
      @@ -105,13 +105,13 @@ function removeReaction(emoji) { diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index 27483cc7c2..6559307a94 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -81,14 +81,14 @@ const exampleCWNote = reactive({ diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index d3e77b2818..7a2e878931 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -23,7 +23,7 @@ import { acct } from '@/filters/user.js'; const props = withDefaults(defineProps<{ user: Misskey.entities.User; - withChart: boolean; + withChart?: boolean; }>(), { withChart: true, }); @@ -49,7 +49,7 @@ $bodyInfoHieght: 16px; display: flex; align-items: center; padding: 16px; - background: var(--panel); + background: var(--MI_THEME-panel); border-radius: 8px; } @@ -64,7 +64,7 @@ $bodyInfoHieght: 16px; flex: 1; overflow: hidden; font-size: 0.9em; - color: var(--fg); + color: var(--MI_THEME-fg); padding-right: 8px; } diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index f0b9606590..0164515a8a 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -69,7 +69,7 @@ defineProps<{ z-index: 2; width: 58px; height: 58px; - border: solid 4px var(--panel); + border: solid 4px var(--MI_THEME-panel); } .title { @@ -90,7 +90,7 @@ defineProps<{ margin: 0; line-height: 16px; font-size: 0.8em; - color: var(--fg); + color: var(--MI_THEME-fg); opacity: 0.7; } @@ -108,7 +108,7 @@ defineProps<{ .description { padding: 16px; font-size: 0.8em; - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); } .mfm { @@ -120,7 +120,7 @@ defineProps<{ .status { padding: 10px 16px; - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); } .statusItem { @@ -131,12 +131,12 @@ defineProps<{ .statusItemLabel { margin: 0; font-size: 0.7em; - color: var(--fg); + color: var(--MI_THEME-fg); } .statusItemValue { font-size: 1em; - color: var(--accent); + color: var(--MI_THEME-accent); } .follow { diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 17a9254d01..8b4afd7994 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -39,6 +39,6 @@ const props = withDefaults(defineProps<{ .root { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - grid-gap: var(--margin); + grid-gap: var(--MI-margin); } diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue index c39a900bcf..5cebeea2f4 100644 --- a/packages/frontend/src/components/MkUserOnlineIndicator.vue +++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue @@ -36,7 +36,7 @@ const text = computed(() => { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 3194641cdb..7cb48f6afb 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -51,6 +51,11 @@ watch(name, () => { // 空文字列をnullにしたいので??は使うな // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing name: name.value || null, + }, undefined, { + '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': { + title: i18n.ts.yourNameContainsProhibitedWords, + text: i18n.ts.yourNameContainsProhibitedWordsDescription, + }, }); }); diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index bb9af676e2..004edab630 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -61,7 +61,7 @@ async function follow() { z-index: 2; width: 58px; height: 58px; - border: solid 4px var(--panel); + border: solid 4px var(--MI_THEME-panel); } .title { @@ -82,7 +82,7 @@ async function follow() { margin: 0; line-height: 16px; font-size: 0.8em; - color: var(--fg); + color: var(--MI_THEME-fg); opacity: 0.7; } @@ -99,7 +99,7 @@ async function follow() { } .footer { - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); padding: 16px; } diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue index 1fb1eda039..b7261129ef 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
      - +
      {{ i18n.ts._initialAccountSetting.accountCreated }}
      {{ i18n.ts._initialAccountSetting.letsStartAccountSetup }}
      {{ i18n.ts._initialAccountSetting.profileSetting }} @@ -91,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
      - +
      {{ i18n.ts.pushNotification }}
      {{ i18n.tsx._initialAccountSetting.pushNotificationDescription({ name: instance.name ?? host }) }}
      @@ -108,7 +108,7 @@ SPDX-License-Identifier: AGPL-3.0-only
      - +
      {{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}
      {{ i18n.tsx._initialAccountSetting.youCanContinueTutorial({ name: instance.name ?? host }) }}
      @@ -223,7 +223,7 @@ async function later(later: boolean) { .progressBarValue { height: 100%; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB)); transition: all 0.5s cubic-bezier(0,.5,.5,1); } @@ -252,7 +252,7 @@ async function later(later: boolean) { left: 0; flex-shrink: 0; padding: 12px; - border-top: solid 0.5px var(--divider); + border-top: solid 0.5px var(--MI_THEME-divider); -webkit-backdrop-filter: blur(15px); backdrop-filter: blur(15px); } diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 75066bbc32..650e639c4f 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -124,7 +124,7 @@ function choose(visibility: typeof Misskey.noteVisibilities[number]): void { } &.active { - color: var(--accent); + color: var(--MI_THEME-accent); } } diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index cab42cd59d..d098dad9a1 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -62,7 +62,7 @@ async function renderChart() { const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const computedStyle = getComputedStyle(document.documentElement); - const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + const accent = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); const colorRead = accent; const colorWrite = '#2ecc71'; diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index a6c8baeaaa..97c765d81c 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -106,8 +106,8 @@ function showMenu(ev: MouseEvent) { .panel { position: relative; - background: var(--panel); - border-radius: var(--radius); + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); box-shadow: 0 12px 32px rgb(0 0 0 / 25%); } @@ -178,14 +178,14 @@ function showMenu(ev: MouseEvent) { } .statsItemLabel { - color: var(--fgTransparentWeak); + color: var(--MI_THEME-fgTransparentWeak); font-size: 0.9em; } .statsItemCount { font-weight: bold; font-size: 1.2em; - color: var(--accent); + color: var(--MI_THEME-accent); } .tl { @@ -194,7 +194,7 @@ function showMenu(ev: MouseEvent) { .tlHeader { padding: 12px 16px; - border-bottom: solid 1px var(--divider); + border-bottom: solid 1px var(--MI_THEME-divider); } .tlBody { diff --git a/packages/frontend/src/components/MkWaitingDialog.vue b/packages/frontend/src/components/MkWaitingDialog.vue index 60b75b6d30..34fa6b0723 100644 --- a/packages/frontend/src/components/MkWaitingDialog.vue +++ b/packages/frontend/src/components/MkWaitingDialog.vue @@ -47,8 +47,8 @@ watch(() => props.showing, () => { padding: 32px; box-sizing: border-box; text-align: center; - background: var(--panel); - border-radius: var(--radius); + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); width: 250px; &.iconOnly { @@ -65,7 +65,7 @@ watch(() => props.showing, () => { font-size: 32px; &.success { - color: var(--accent); + color: var(--MI_THEME-accent); } &.waiting { diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 0c51cfa9ce..492dd4cdc0 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only