diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c960c95..6f29eaf8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,16 +20,23 @@ - 画像のテンプレートはこちらです: https://misskey-hub.net/avatar-decoration-template.png - 最大でも黄色いエリア内にデコレーションを収めることを推奨します。 - 画像は512x512pxを推奨します。 +- Feat: チャンネル設定にリノート/引用リノートの可否を設定できる項目を追加 +- Enhance: アカウント登録時のメールアドレス認証に30分の有効期限を設定 + - 有効期限が切れた後であれば、登録時に使用した招待コードを再度利用できるように変更しました。 + - ユーザーが誤ったメールアドレスを入力した場合に招待コードが失効してしまう問題が解消されます。 - Enhance: すでにフォローしたすべての人の返信をTLに追加できるように - Enhance: 未読の通知数を表示できるように - Enhance: ローカリゼーションの更新 - Enhance: 依存関係の更新 +- Change: CWを使用する場合、注釈を空にすることは許可されなくなりました ### Client - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください https://misskey-hub.net/docs/advanced/publish-on-your-website.html -- Enhance: スワイプしてタイムラインを再読込できるように +- Feat: 通知をグルーピングして表示するオプション(オプトアウト) +- Feat: Misskeyの基本的なチュートリアルを実装 +- Feat: スワイプしてタイムラインを再読込できるように - PCの場合は右上のボタンからでも再読込できます - Enhance: タイムラインの自動更新を無効にできるように - Enhance: コードのシンタックスハイライトエンジンをShikiに変更 @@ -48,8 +55,13 @@ - Fix: チャンネルの作成・更新時に失敗した場合何も表示されない問題を修正 #11983 - Fix: 個人カードのemojiがバッテリーになっている問題を修正 - Fix: 標準テーマと同じIDを使用してインストールできてしまう問題を修正 +- Fix: 絵文字ピッカーでバッテリーの絵文字が複数表示される問題を修正 #12197 +- Fix: 11以上されているリアクションにおいてツールチップで示されるリアクション数が本来よりも1多い問題を修正 #12174 +- Fix: サイレンス状態で公開範囲のパブリックを選択できてしまう問題を修正 #12224 +- Fix: In deck layout, replies option is not saved after refresh ### Server +- Feat: Registry APIがサードパーティから利用可能になりました - Enhance: RedisへのTLのキャッシュ(FTT)をオフにできるように - Enhance: フォローしているチャンネルをフォロー解除した時(またはその逆)、タイムラインに反映される間隔を改善 - Enhance: プロフィールの自己紹介欄のMFMが連合するようになりました diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 484fd99413..13e0656041 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions or troubleshooting in [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 27f69ad5af..d62990b7b7 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1262,9 +1262,6 @@ _time: minute: "د" hour: "سا" day: "ي" -_timelineTutorial: - title: "كيف تستخدم Misskey" - step3_1: "هل نشرت ملاحظتك الأولى؟" _2fa: alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين." step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})." diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index c79359da3e..38c52372c3 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1109,7 +1109,6 @@ _initialAccountSetting: pushNotificationDescription: "Povolení push oznámení vám umožní přijímat oznámení od {name} přímo ve vašem zařízení." initialAccountSettingCompleted: "Nastavení profilu dokončeno!" haveFun: "Užívejte {name}!" - ifYouNeedLearnMore: "Pokud se chcete dozvědět více o tom, jak používat {name} (Misskey), navštivte {link}." skipAreYouSure: "Opravdu chcete přeskočit nastavení profilu?" laterAreYouSure: "Opravdu chcete provést nastavení profilu později?" _serverRules: @@ -1658,16 +1657,6 @@ _time: minute: "Minut" hour: "Hodin" day: "Dnů" -_timelineTutorial: - title: "Jak používat Misskey" - step1_1: "Toto je \"časová osa\". Zde se chronologicky zobrazují všechny \"poznámky\" odeslané na {name}." - step1_2: "Existuje několik různých časových plánů. Například \"Domácí časová osa\" bude obsahovat poznámky uživatelů, které sledujete, a \"Místní časová osa\" bude obsahovat poznámky všech uživatelů {name}." - step2_1: "Zkusme zveřejnit poznámku. Můžete tak učinit stisknutím tlačítka s ikonou tužky." - step2_2: "Co takhle napsat sebepředstavení, nebo jen \"Ahoj {name}!\", pokud se vám nechce?" - step3_1: "Dokončil jsi svou první poznámku?" - step3_2: "Na časové ose by se nyní měla zobrazit vaše první poznámka." - step4_1: "K poznámkám můžete také připojit \"Reakce\"." - step4_2: "Chcete-li připojit reakci, stiskněte na poznámce znaménko \"+\" a vyberte emoji, kterým chcete reagovat." _2fa: alreadyRegistered: "Již jste zaregistrovali dvoufaktorové ověřovací zařízení." registerTOTP: "Registrovat aplikaci autentizátoru" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 8d6d453674..d6f965a432 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1154,6 +1154,8 @@ releaseToRefresh: "Zum Aktualisieren loslassen" refreshing: "Wird aktualisiert..." pullDownToRefresh: "Zum Aktualisieren ziehen" disableStreamingTimeline: "Echtzeitaktualisierung der Chronik deaktivieren" +useGroupedNotifications: "Benachrichtigungen gruppieren" +cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden." _announcement: forExistingUsers: "Nur für existierende Nutzer" forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." @@ -1175,7 +1177,6 @@ _initialAccountSetting: pushNotificationDescription: "Durch die Aktivierung von Push-Benachrichtigungen kannst du von {name} Benachrichtigungen direkt auf dein Gerät erhalten." initialAccountSettingCompleted: "Kontoeinrichtung abgeschlossen!" haveFun: "Viel Spaß mit {name}!" - ifYouNeedLearnMore: "Besuche {link}, falls du mehr über {name} (Sharkey) lernen möchtest." skipAreYouSure: "Die Kontoeinrichtung wirklich überspringen?" laterAreYouSure: "Die Kontoeinrichtung wirklich später erledigen?" _serverRules: @@ -1742,16 +1743,6 @@ _time: minute: "Minute(n)" hour: "Stunde(n)" day: "Tag(en)" -_timelineTutorial: - title: "Wie du Sharkey verwendest" - step1_1: "Dieser Bildschirm ist die \"Chronik\". Hier werden alle \"Notizen\" von {name} angezeigt." - step1_2: "Es gibt einige verschiedene Chroniken. Beispielsweise werden in der \"Startseite\" alle Notizen von Nutzern, denen du folgst, angezeigt, und in der \"Lokalen Chronik\" werden Notizen aller Nutzer auf {name} angezeigt." - step2_1: "Lass uns als nächstes versuchen, eine Notiz zu schreiben. Dies kannst du tun, indem du auf den Knopf mit dem Stift-Icon drückst." - step2_2: "Stell dich den anderen vor oder schreibe einfach \"Hallo {name}!\", wenn du darauf keine Lust hast oder dir nichts einfällt." - step3_1: "Fertig mit dem Senden deiner ersten Notiz?" - step3_2: "Falls deine Notiz nun in deiner Chronik auftaucht, hast du alles richtig gemacht." - step4_1: "Notizen können zusätzlich mit \"Reaktionen\" ausgestattet werden." - step4_2: "Um eine Reaktion anzufügen, klicke auf das „+“-Symbol einer Notiz und wähle ein Emoji aus, mit dem du reagieren möchtest." _2fa: alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung registriert." registerTOTP: "Authentifizierungs-App registrieren" @@ -2061,6 +2052,9 @@ _notification: checkNotificationBehavior: "Aussehen von Benachrichtigungen überprüfen" sendTestNotification: "Testbenachrichtigung senden" notificationWillBeDisplayedLikeThis: "Benachrichtigungen sehen so aus" + reactedBySomeUsers: "{n} Benutzer haben eine Reaktion geschickt" + renotedBySomeUsers: "Renote von {n} Benutzern" + followedBySomeUsers: "Von {n} Benutzern gefolgt" _types: all: "Alle" note: "Neue Notizen" diff --git a/locales/en-US.yml b/locales/en-US.yml index 3bb8739f19..a2a2efffde 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1184,6 +1184,8 @@ releaseToRefresh: "Release to refresh" refreshing: "Refreshing..." pullDownToRefresh: "Pull down to refresh" disableStreamingTimeline: "Disable real-time timeline updates" +useGroupedNotifications: "Display grouped notifications" +cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided." _announcement: forExistingUsers: "Existing users only" forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it." @@ -1205,7 +1207,6 @@ _initialAccountSetting: pushNotificationDescription: "Enabling push notifications will allow you to receive notifications from {name} directly on your device." initialAccountSettingCompleted: "Profile setup complete!" haveFun: "Enjoy {name}!" - ifYouNeedLearnMore: "If you'd like to learn more about how to use {name} (Sharkey), please visit {link}." skipAreYouSure: "Really skip profile setup?" laterAreYouSure: "Really do profile setup later?" _serverRules: @@ -1774,16 +1775,6 @@ _time: minute: "Minute(s)" hour: "Hour(s)" day: "Day(s)" -_timelineTutorial: - title: "How to use Sharkey" - step1_1: "This is the \"timeline\". All \"notes\" submitted on {name} will be chronologically displayed here." - step1_2: "There are a few different timelines. For example, the \"Home timeline\" will contain notes of users you follow, and the \"Local timeline\" will contain notes from all users of {name}." - step2_1: "Let's try posting a note next. You can do so by pressing the button with a pencil icon." - step2_2: "How about writing a self-introduction, or just \"Hello {name}!\" if you don't feel like it?" - step3_1: "Finished posting your first note?" - step3_2: "Your first note should now be displayed on your timeline." - step4_1: "You can also attach \"Reactions\" to notes." - step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with." _2fa: alreadyRegistered: "You have already registered a 2-factor authentication device." registerTOTP: "Register authenticator app" @@ -2094,6 +2085,9 @@ _notification: checkNotificationBehavior: "Check notification appearance" sendTestNotification: "Send test notification" notificationWillBeDisplayedLikeThis: "Notifications look like this" + reactedBySomeUsers: "{n} users reacted" + renotedBySomeUsers: "Renote from {n} users" + followedBySomeUsers: "Followed by {n} users" _types: all: "All" note: "New notes" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 623dafee3f..0fff937d84 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -1161,7 +1161,6 @@ _initialAccountSetting: pushNotificationDescription: "Habilitar las notificaciones push te permitirá recibir notificaciones de {name} directamente en tu dispositivo." initialAccountSettingCompleted: "¡Configuración del perfil completada!" haveFun: "¡Disfruta de {name}!" - ifYouNeedLearnMore: "Si quieres aprender cómo usar {name} (Misskey), por favor, visita {link}." skipAreYouSure: "¿Realmente quieres saltarte la configuración del perfil?" laterAreYouSure: "¿Realmente quieres configurar tu perfil después?" _serverRules: @@ -1725,16 +1724,6 @@ _time: minute: "Minutos" hour: "Horas" day: "Días" -_timelineTutorial: - title: "Cómo usar Misskey" - step1_1: "Ésta es la \"línea de tiempo\". Todas las \"notas\" que sean publicadas en {name} serán mostradas cronológicamente aquí." - step1_2: "Hay varias líneas de tiempo. Por ejemplo, la línea temporal \"Inicio\" contiene las notas de otros usuarios que sigues, y la línea \"Local\" contandrá las notas de todos los usuarios de {name}." - step2_1: "Ahora probemos publicar una nota. Puedes hacerlo presionando el botón que tiene un ícono de lápiz." - step2_2: "¿Qué tal si escribimos una introducción? o sólo un \"¡Hola {name}!\" ¿No te apetece?" - step3_1: "¿Terminaste de publicar tu primera nota?" - step3_2: "Tu primera nota ahora se mostrará en tu línea de tiempo." - step4_1: "También puedes añadir \"Reacciones\" a notas." - step4_2: "Para añadir una reacción selecciona el botón \"+\" en la nota y escoge el emoji que quieras para reaccionar." _2fa: alreadyRegistered: "Ya has completado la configuración." registerTOTP: "Registrar aplicación autenticadora" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index e88025292c..785e178d70 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1081,13 +1081,13 @@ releaseToRefresh: "Relâcher pour rafraîchir" refreshing: "Rafraîchissement..." pullDownToRefresh: "Tirer vers le bas pour rafraîchir" disableStreamingTimeline: "Désactiver les mises à jour en temps réel de la ligne du temps" +useGroupedNotifications: "Grouper les notifications" _announcement: readConfirmTitle: "Marquer comme lu ?" _initialAccountSetting: profileSetting: "Paramètres du profil" privacySetting: "Paramètres de confidentialité" initialAccountSettingCompleted: "Configuration du profil terminée avec succès !" - ifYouNeedLearnMore: "Si vous voulez en savoir plus comment utiliser {name}(Misskey), veuillez visiter {link}." skipAreYouSure: "Désirez-vous ignorer la configuration du profil ?" _serverSettings: iconUrl: "URL de l’icône" @@ -1468,9 +1468,6 @@ _time: minute: "min" hour: "h" day: "j" -_timelineTutorial: - title: "Comment utiliser Misskey" - step3_1: "Avez-vous publié votre première note ?" _2fa: alreadyRegistered: "Configuration déjà achevée." step1: "Tout d'abord, installez une application d'authentification, telle que {a} ou {b}, sur votre appareil." @@ -1738,6 +1735,9 @@ _notification: unreadAntennaNote: "Antenne {name}" emptyPushNotificationMessage: "Les notifications push ont été mises à jour" achievementEarned: "Accomplissement" + reactedBySomeUsers: "{n} utilisateur·rice·s ont réagi" + renotedBySomeUsers: "{n} utilisateur·rice·s ont renoté" + followedBySomeUsers: "{n} utilisateur·rice·s se sont abonné·e·s à vous" _types: all: "Toutes" follow: "Nouvel·le abonné·e" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 32e27b94ea..8e516bc0fc 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -1161,7 +1161,6 @@ _initialAccountSetting: pushNotificationDescription: "Menyalakan notifikasi dorong akan membuatmu menerima notifikasi dari {name} secara langsung ke perangkatmu." initialAccountSettingCompleted: "Pengaturan profil selesai!" haveFun: "Selamat menikmati, {name}!" - ifYouNeedLearnMore: "Kalau kamu ingin mempelajari lebih lanjut bagaimana cara menggunakan {name} (Misskey), silahkan kunjungi {link}." skipAreYouSure: "Yakin melewati atur profil?" laterAreYouSure: "Yakin banget untuk atur profil nanti?" _serverRules: @@ -1725,16 +1724,6 @@ _time: minute: "menit" hour: "jam" day: "hari" -_timelineTutorial: - title: "Bagaimana cara menggunakan Misskey" - step1_1: "Ini adalah \"lini masa\". Semua \"catatan\" yang dikirimkan oleh {name} akan dimunculkan secara kronologis di sini." - step1_2: "Ada beberapa lini masa yang berbeda. Seperti contoh, \"Lini masa Beranda\" berisi catatan dari pengguna yang kamu ikuti, dan \"Lini masa lokal\" berisi catatan dari semua pengguna dari {name}." - step2_1: "Selanjutnya, mari kita coba memposting sebuah catatan. Kamu dapat melakukanya dengan menekan tombol dengan ikon pensil." - step2_2: "Bagaimana dengan menuliskan sedikit perkenalan diri, atau hanya \"Hello {name}\" kalau kamu lagi ngga feeling?" - step3_1: "Udah selesai memposting catatan pertamamu?" - step3_2: "Catatan pertamamu seharusnya sekarang sudah tampil di lini masa kamu." - step4_1: "Kamu dapat menyisipkan \"Reaksi\" ke dalam catatan." - step4_2: "Untuk menyisipkan reaksi, tekan tanda \"+\" dalam catatan dan pilih emoji yang kamu suka untuk mereaksi catatan tersebut." _2fa: alreadyRegistered: "Kamu telah mendaftarkan perangkat autentikasi 2-faktor." registerTOTP: "Daftarkan aplikasi autentikator" diff --git a/locales/index.d.ts b/locales/index.d.ts index b2dec3e58b..b67e2e2a95 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1187,6 +1187,9 @@ export interface Locale { "refreshing": string; "pullDownToRefresh": string; "disableStreamingTimeline": string; + "useGroupedNotifications": string; + "signupPendingError": string; + "cwNotationRequired": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1196,6 +1199,8 @@ export interface Locale { "tooManyActiveAnnouncementDescription": string; "readConfirmTitle": string; "readConfirmText": string; + "shouldNotBeUsedToPresentPermanentInfo": string; + "dialogAnnouncementUxWarn": string; }; "_initialAccountSetting": { "accountCreated": string; @@ -1209,10 +1214,91 @@ export interface Locale { "pushNotificationDescription": string; "initialAccountSettingCompleted": string; "haveFun": string; - "ifYouNeedLearnMore": string; + "youCanContinueTutorial": string; + "startTutorial": string; "skipAreYouSure": string; "laterAreYouSure": string; }; + "_initialTutorial": { + "launchTutorial": string; + "title": string; + "wellDone": string; + "skipAreYouSure": string; + "_landing": { + "title": string; + "description": string; + }; + "_note": { + "title": string; + "description": string; + "reply": string; + "renote": string; + "reaction": string; + "menu": string; + }; + "_reaction": { + "title": string; + "description": string; + "letsTryReacting": string; + "reactToContinue": string; + "reactNotification": string; + "reactDone": string; + }; + "_timeline": { + "title": string; + "description1": string; + "home": string; + "local": string; + "social": string; + "global": string; + "description2": string; + "description3": string; + }; + "_postNote": { + "title": string; + "description1": string; + "_visibility": { + "description": string; + "public": string; + "home": string; + "followers": string; + "direct": string; + "doNotSendConfidencialOnDirect1": string; + "doNotSendConfidencialOnDirect2": string; + "localOnly": string; + }; + "_cw": { + "title": string; + "description": string; + "_exampleNote": { + "cw": string; + "note": string; + }; + "useCases": string; + }; + }; + "_howToMakeAttachmentsSensitive": { + "title": string; + "description": string; + "tryThisFile": string; + "_exampleNote": { + "note": string; + }; + "method": string; + "sensitiveSucceeded": string; + "doItToContinue": string; + }; + "_done": { + "title": string; + "description": string; + }; + }; + "_timelineDescription": { + "home": string; + "local": string; + "social": string; + "global": string; + }; "_serverRules": { "description": string; }; @@ -1560,6 +1646,10 @@ export interface Locale { "title": string; "description": string; }; + "_tutorialCompleted": { + "title": string; + "description": string; + }; }; }; "_role": { @@ -1774,6 +1864,7 @@ export interface Locale { "notesCount": string; "nameAndDescription": string; "nameOnly": string; + "allowRenoteToExternal": string; }; "_menuDisplay": { "sideFull": string; @@ -1890,17 +1981,6 @@ export interface Locale { "hour": string; "day": string; }; - "_timelineTutorial": { - "title": string; - "step1_1": string; - "step1_2": string; - "step2_1": string; - "step2_2": string; - "step3_1": string; - "step3_2": string; - "step4_1": string; - "step4_2": string; - }; "_2fa": { "alreadyRegistered": string; "registerTOTP": string; @@ -2233,6 +2313,9 @@ export interface Locale { "checkNotificationBehavior": string; "sendTestNotification": string; "notificationWillBeDisplayedLikeThis": string; + "reactedBySomeUsers": string; + "renotedBySomeUsers": string; + "followedBySomeUsers": string; "_types": { "all": string; "note": string; diff --git a/locales/it-IT.yml b/locales/it-IT.yml index b56040e4ad..2e5723c4a0 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -326,9 +326,9 @@ avatar: "Foto del profilo" banner: "Intestazione" displayOfSensitiveMedia: "Visibilità dei media espliciti" whenServerDisconnected: "Quando la connessione col server è persa" -disconnectedFromServer: "Il server si è disconnesso" +disconnectedFromServer: "Connessione persa" reload: "Ricarica" -doNothing: "Nessun'azione" +doNothing: "Ignora" reloadConfirm: "Vuoi ricaricare?" watch: "Osserva" unwatch: "Smetti di Osserva" @@ -653,7 +653,7 @@ notificationSetting: "Impostazioni notifiche" notificationSettingDesc: "Seleziona il tipo di notifiche da visualizzare." useGlobalSetting: "Usa impostazioni generali" useGlobalSettingDesc: "Quando attiva, verranno utilizzate le impostazioni notifiche del profilo. Altrimenti si possono segliere impostazioni personalizzate." -other: "Avanzate" +other: "Ulteriori" regenerateLoginToken: "Genera di nuovo un token di connessione" regenerateLoginTokenDescription: "Genera un nuovo token di autenticazione. Solitamente questa operazione non è necessaria: quando si genera un nuovo token, tutti i dispositivi vanno disconnessi." setMultipleBySeparatingWithSpace: "È possibile creare multiple voci separate da spazi." @@ -1154,6 +1154,7 @@ releaseToRefresh: "Rilascia per aggiornare" refreshing: "Aggiornamento..." pullDownToRefresh: "Trascina per aggiornare" disableStreamingTimeline: "Disabilitare gli aggiornamenti della TL in tempo reale" +useGroupedNotifications: "Mostra le notifiche raggruppate" _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." @@ -1175,7 +1176,6 @@ _initialAccountSetting: pushNotificationDescription: "Attivare le notifiche push ti permettera di ricevere informazioni sulla attività di {name} direttamente sul tuo dispositivo." initialAccountSettingCompleted: "Hai completato la configurazione iniziale!" haveFun: "Divertiti con {name}!" - ifYouNeedLearnMore: "Per saperne di più su come usare {name} (Misskey), visita la pagina {link}" skipAreYouSure: "Vuoi davvero saltare la configurazione iniziale?" laterAreYouSure: "Vuoi davvero rimandare la configurazione iniziale?" _serverRules: @@ -1742,16 +1742,6 @@ _time: minute: "min" hour: "ore" day: "giorni" -_timelineTutorial: - title: "Come usare Misskey" - step1_1: "Questa è la \"Timeline\". tutte le \"Note\" pubblicate su {name} vengono elencate in ordine cronologico." - step1_2: "Le Timeline sono diverse, ad esempio, la \"Home\" elenca le Note dei profili che segui. Quella \"Locale\" elenca quelle di tutti i profili attivi su {name}." - step2_1: "Prova a pubblicare una Nota. Semplicemente premendo il bottone con l'icona di una matita." - step2_2: "Potresti scrivere la tua presentazione, oppure semplicemente \"Ciao da {name}!\"" - step3_1: "Hai pubblicato qualcosa?" - step3_2: "In tal caso, dovrebbe comparire subito nella tua \"Home\"" - step4_1: "Puoi reagire con un emoji alle Note." - step4_2: "To attach a reaction, press the \"+\" mark on a note and choose an emoji you'd like to react with.\nPer reagire con una emoji, premi il bottone \"+\" (più) visibile vicino ad ogni Nota e scegli dall'elenco la emoji che rappresenta la tua reazione." _2fa: alreadyRegistered: "La configurazione è stata già completata." registerTOTP: "Registra un'app di autenticazione" @@ -1851,8 +1841,8 @@ _widgets: calendar: "Calendario" trends: "Di tendenza" clock: "Orologio" - rss: "Aggregatore rss" - rssTicker: "Ticker RSS" + rss: "Lettura RSS" + rssTicker: "Nastro RSS" activity: "Attività" photos: "Foto" digitalClock: "Orologio digitale" @@ -1874,7 +1864,7 @@ _widgets: clicker: "Cliccaggio" _cw: hide: "Nascondere" - show: "Apri..." + show: "Attenzione: continua la lettura" chars: "{count} caratteri" files: "{count} file" _poll: @@ -2061,6 +2051,9 @@ _notification: checkNotificationBehavior: "Prova il comportamento della notifica" sendTestNotification: "Spedisci una notifica di prova" notificationWillBeDisplayedLikeThis: "La notifica apparirà così" + reactedBySomeUsers: "{n} reazioni" + renotedBySomeUsers: "{n} Rinota" + followedBySomeUsers: "{n} nuovi follower" _types: all: "Tutto" note: "Nuove Note" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c49de02b1a..b9748ce4d4 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1165,8 +1165,8 @@ showRepliesToOthersInTimeline: "TLに他の人への返信を含める" hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない" showRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めるようにする" hideRepliesToOthersInTimelineAll: "TLに現在フォロー中の人全員の返信を含めないようにする" -confirmShowRepliesAll: "この操作は元の戻せません。本当にTLに現在フォロー中の人全員の返信を含めるようにしますか" -confirmHideRepliesAll: "この操作は元の戻せません。本当にTLに現在フォロー中の人全員の返信を含めないようにしますか" +confirmShowRepliesAll: "この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めるようにしますか?" +confirmHideRepliesAll: "この操作は元に戻せません。本当にTLに現在フォロー中の人全員の返信を含めないようにしますか?" externalServices: "外部サービス" impressum: "運営者情報" impressumUrl: "運営者情報URL" @@ -1184,6 +1184,9 @@ releaseToRefresh: "離してリロード" refreshing: "リロード中" pullDownToRefresh: "引っ張ってリロード" disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" +useGroupedNotifications: "通知をグルーピングして表示する" +signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" +cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1194,10 +1197,12 @@ _announcement: tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。" readConfirmTitle: "既読にしますか?" readConfirmText: "「{title}」の内容を読み、既読にします。" + shouldNotBeUsedToPresentPermanentInfo: "特に新規ユーザーのUXを損ねる可能性が高いため、ストック情報ではなくフロー情報の掲示にお知らせを使用することを推奨します。" + dialogAnnouncementUxWarn: "ダイアログ形式のお知らせが同時に2つ以上ある場合、UXに悪影響を及ぼす可能性が非常に高いため、使用は慎重に行うことを推奨します。" _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" - letsStartAccountSetup: "アカウントの初期設定を行いましょう。" + letsStartAccountSetup: "さっそくアカウントの初期設定を行いましょう。" letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。" profileSetting: "プロフィール設定" privacySetting: "プライバシー設定" @@ -1207,10 +1212,80 @@ _initialAccountSetting: pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。" initialAccountSettingCompleted: "初期設定が完了しました!" haveFun: "{name}をお楽しみください!" - ifYouNeedLearnMore: "{name}(Sharkey)の使い方などを詳しく知るには{link}をご覧ください。" + youCanContinueTutorial: "このまま{name}(Sharkey)の使い方についてのチュートリアルに進むこともできますが、ここで中断してすぐに使い始めることもできます。" + startTutorial: "チュートリアルを開始" skipAreYouSure: "初期設定をスキップしますか?" laterAreYouSure: "初期設定をあとでやり直しますか?" +_initialTutorial: + launchTutorial: "チュートリアルを見る" + title: "チュートリアル" + wellDone: "よくできました" + skipAreYouSure: "チュートリアルを終了しますか?" + _landing: + title: "チュートリアルへようこそ" + description: "ここでは、Misskeyの基本的な使い方や機能を確認できます。" + _note: + title: "ノートって何?" + description: "Misskeyでの投稿は「ノート」と呼びます。ノートはタイムラインに時系列で並んでいて、リアルタイムで更新されていきます。" + reply: "返信することができます。返信に対しての返信も可能で、スレッドのように会話を続けることもできます。" + renote: "そのノートを自分のタイムラインに流して共有することができます。テキストを追加して引用することも可能です。" + reaction: "リアクションをつけることができます。詳しくは次のページで解説します。" + menu: "ノートの詳細を表示したり、リンクをコピーしたりなどの様々な操作が行えます。" + _reaction: + title: "リアクションって何?" + description: "ノートには「リアクション」をつけることができます。「いいね」では伝わらないニュアンスも、リアクションで簡単・気軽に表現できます。" + letsTryReacting: "リアクションは、ノートの「+」ボタンをクリックするとつけられます。試しにこのサンプルのノートにリアクションをつけてみてください!" + reactToContinue: "リアクションをつけると先に進めるようになります。" + reactNotification: "あなたのノートが誰かにリアクションされると、リアルタイムで通知を受け取ります。" + reactDone: "「ー」ボタンを押すとリアクションを取り消すことができます。" + _timeline: + title: "タイムラインのしくみ" + description1: "Misskeyには、使い方に応じて複数のタイムラインが用意されています(サーバーによってはいずれかが無効になっていることがあります)。" + home: "あなたがフォローしているアカウントの投稿を見られます。" + local: "このサーバーにいるユーザー全員の投稿を見られます。" + social: "ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + global: "接続している他のすべてのサーバーからの投稿を見られます。" + description2: "それぞれのタイムラインは、画面上部でいつでも切り替えられます。" + description3: "その他にも、リストタイムラインやチャンネルタイムラインなどがあります。詳しくは{link}をご覧ください。" + _postNote: + title: "ノートの投稿設定" + description1: "Misskeyにノートを投稿する際には、様々なオプションの設定が可能です。投稿フォームはこのようになっています。" + _visibility: + description: "ノートを表示できる相手を制限できます。" + public: "すべてのユーザーに公開。" + home: "ホームタイムラインのみに公開。フォロワー・プロフィールを見に来た人・リノートから、他のユーザーも見ることができます。" + followers: "フォロワーにのみ公開。本人以外がリノートすることはできず、またフォロワー以外は閲覧できません。" + direct: "指定したユーザーにのみ公開され、また相手に通知が入ります。ダイレクトメッセージのかわりにお使いいただけます。" + doNotSendConfidencialOnDirect1: "機密情報は送信する際は注意してください。" + doNotSendConfidencialOnDirect2: "送信先のサーバーの管理者は投稿内容を見ることが可能なので、信頼できないサーバーのユーザーにダイレクト投稿を送信する場合は、機密情報の扱いに注意が必要です。" + localOnly: "他のサーバーに投稿を連合しません。上記の公開範囲に関わらず、他のサーバーのユーザーは、この設定がついたノートを直接閲覧することができなくなります。" + _cw: + title: "内容を隠す(CW)" + description: "本文のかわりに「注釈」に書いた内容が表示されます。「もっと見る」を押すと本文が表示されます。" + _exampleNote: + cw: "飯テロ注意" + note: "チョコのかかったドーナツを食べました🍩😋" + useCases: "サーバーのガイドラインにより必要とされるノートに指定したり、ネタバレ投稿やセンシティブな文章を自主規制したりするときに使います。" + _howToMakeAttachmentsSensitive: + title: "添付ファイルをセンシティブにするには?" + description: "サーバーのガイドラインにより必要とされる際や、そのまま見れる状態にしておくべきではない添付ファイルには、「センシティブ」設定を付けます。" + tryThisFile: "試しに、このフォームに添付された画像をセンシティブにしてみてください!" + _exampleNote: + note: "納豆のフタ開けるのミスったわね…" + method: "添付ファイルをセンシティブにする際は、そのファイルをクリックしてメニューを開き、「センシティブとして設定」をクリックします。" + sensitiveSucceeded: "ファイルを添付する際は、サーバーのガイドラインに従ってセンシティブを適切に設定してください。" + doItToContinue: "画像をセンシティブに設定すると先に進めるようになります。" + _done: + title: "チュートリアルは終了です🎉" + description: "ここで紹介した機能はほんの一部にすぎません。Misskeyの使い方をより詳しく知るには、{link}をご覧ください。" + +_timelineDescription: + home: "ホームタイムラインでは、あなたがフォローしているアカウントの投稿を見られます。" + local: "ローカルタイムラインでは、このサーバーにいるユーザー全員の投稿を見られます。" + social: "ソーシャルタイムラインには、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + global: "グローバルタイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" + _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" @@ -1483,6 +1558,9 @@ _achievements: _smashTestNotificationButton: title: "テスト過剰" description: "通知のテストをごく短時間のうちに連続して行った" + _tutorialCompleted: + title: "Misskey初心者講座 修了証" + description: "チュートリアルを完了した" _role: new: "ロールの作成" @@ -1582,7 +1660,7 @@ _ffVisibility: _signup: almostThere: "ほとんど完了です" emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。" - emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。" + emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。メールに記載されているリンクの有効期限は30分です。" approvalPending: "アカウントが作成され、承認待ちの状態です。" reasonInfo: "インスタンスに参加したい理由を入力してください。" @@ -1691,6 +1769,7 @@ _channel: notesCount: "{n}投稿があります" nameAndDescription: "名前と説明" nameOnly: "名前のみ" + allowRenoteToExternal: "チャンネル外へのリノートと引用リノートを許可する" _menuDisplay: sideFull: "横" @@ -1807,17 +1886,6 @@ _time: hour: "時間" day: "日" -_timelineTutorial: - title: "Sharkeyの使い方" - step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。" - step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。" - step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" - step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。" - step3_1: "投稿できましたか?" - step3_2: "あなたのノートがタイムラインに表示されていれば成功です。" - step4_1: "ノートには、「リアクション」を付けることができます。" - step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。" - _2fa: alreadyRegistered: "既に設定は完了しています。" registerTOTP: "認証アプリの設定を開始" @@ -2147,6 +2215,9 @@ _notification: checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" notificationWillBeDisplayedLikeThis: "通知はこのように表示されます" + reactedBySomeUsers: "{n}人がリアクションしました" + renotedBySomeUsers: "{n}人がリノートしました" + followedBySomeUsers: "{n}人にフォローされました" _types: all: "すべて" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 90175c5174..6753db3b15 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1135,6 +1135,9 @@ fileAttachedOnly: "ファイル付きのみ" showRepliesToOthersInTimeline: "タイムラインに他の人への返信とかも含めんで" hideRepliesToOthersInTimeline: "タイムラインに他の人への返信とかは見ーへんで" showRepliesToOthersInTimelineAll: "" +hideRepliesToOthersInTimelineAll: "" +confirmShowRepliesAll: "" +confirmHideRepliesAll: "" externalServices: "他のサイトのサービス" impressum: "運営者の情報" impressumUrl: "運営者の情報URL" @@ -1143,7 +1146,11 @@ privacyPolicy: "プライバシーポリシー" privacyPolicyUrl: "プライバシーポリシーURL" tosAndPrivacyPolicy: "利用規約・プライバシーポリシー" avatarDecorations: "アイコンデコレーション" +attach: "" +detach: "" +angle: "" flip: "反転" +showAvatarDecorations: "" _announcement: forExistingUsers: "もうおるユーザーのみ" forExistingUsersDescription: "有効にすると、このお知らせ作成時点でおるユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" @@ -1165,7 +1172,6 @@ _initialAccountSetting: pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をあんたのデバイスで受け取れるで。" initialAccountSettingCompleted: "初期設定が終わったで。" haveFun: "{name}、楽しんでな~" - ifYouNeedLearnMore: "{name}(Sharkey)の使い方とかをよー知りたいんやったら{link}をみてな。" skipAreYouSure: "初期設定飛ばすか?" laterAreYouSure: "初期設定あとでやり直すん?" _serverRules: @@ -1179,6 +1185,7 @@ _serverSettings: manifestJsonOverride: "manifest.jsonのオーバーライド" shortName: "略称" shortNameDescription: "サーバーの名前が長い時に、代わりに表示することのできるあだ名。" + fanoutTimelineDescription: "" _accountMigration: moveFrom: "別のアカウントからこのアカウントに引っ越す" moveFromSub: "別のアカウントへエイリアスを作る" @@ -1598,6 +1605,7 @@ _aboutMisskey: donate: "Sharkeyに寄付" morePatrons: "他にもぎょうさんの人からサポートしてもろてんねん。ほんまおおきに🥰" patrons: "支援者" + projectMembers: "" _displayOfSensitiveMedia: respect: "きわどいのは見とうない" ignore: "きわどいのも見たい" @@ -1729,16 +1737,6 @@ _time: minute: "分" hour: "時間" day: "日" -_timelineTutorial: - title: "Sharkeyってなんや?" - step1_1: "これは「タイムライン」や。{name}に投稿された「ノート」が順番に表示されるで。" - step1_2: "タイムラインには何個か種類があってな、例えば「ホームタイムライン」だったらあんたのフォローしてる人のノート、「ローカルタイムライン」には{name}全部のノートが流れてくるで。" - step2_1: "試しに、何かノートを投稿してみ。画面の鉛筆マークのボタンでフォームが開くで。" - step2_2: "最初のノートは、自己紹介とか「{name}始めてみたんや」とかがええと思うで。" - step3_1: "投稿できた?" - step3_2: "あんたのノートがタイムラインに出てきたら成功や。" - step4_1: "ノートには、「ツッコミ」を付けれるで。" - step4_2: "ツッコむんやったら、ノートの「+」マークを押して、好きな絵文字を選ぶんやで。" _2fa: alreadyRegistered: "もう設定終わっとるわ。" registerTOTP: "認証アプリの設定はじめる" @@ -2171,7 +2169,21 @@ _externalResourceInstaller: _theme: title: "このテーマインストールする?" metaTitle: "テーマ情報" + _meta: + base: "" + _vendorInfo: + title: "" + endpoint: "" + hashVerify: "" _errors: + _invalidParams: + title: "" + description: "" + _resourceTypeNotSupported: + title: "" + description: "" + _failedToFetch: + title: "" _pluginParseFailed: title: "AiScriptエラー起こしてもうたねん" description: "データは取得できたものの、AiScript解析時にエラーがあったから読み込めへんかってん。すまんが、プラグインを作った人に問い合わせてくれへん?ごめんな。エラーの詳細はJavaScriptコンソール読んでな。" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 30481ffc3e..8609bad2e5 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1123,6 +1123,7 @@ edited: "수정됨" notificationRecieveConfig: "알림 설정" mutualFollow: "맞팔로우" flip: "플립" +useGroupedNotifications: "알림을 그룹화하고 표시" _announcement: forExistingUsers: "기존 유저에게만 알림" forExistingUsersDescription: "활성화하면 이 공지사항을 게시한 시점에서 이미 가입한 유저에게만 표시합니다. 비활성화하면 게시 후에 가입한 유저에게도 표시합니다." @@ -1144,7 +1145,6 @@ _initialAccountSetting: pushNotificationDescription: "푸시 알림을 활성화하면 {name}의 알림을 나의 기기에서 받아볼 수 있게 됩니다." initialAccountSettingCompleted: "초기 설정을 모두 마쳤습니다!" haveFun: "{name}와 함께 즐거운 시간 보내세요!" - ifYouNeedLearnMore: "{name}(Misskey)의 사용 방법에 대해 자세히 알아보려면 {link}를 참고해 주세요." skipAreYouSure: "초기 설정을 중단하시겠습니까?" laterAreYouSure: "초기 설정을 나중에 진행하시겠습니까?" _serverRules: @@ -1699,16 +1699,6 @@ _time: minute: "분" hour: "시간" day: "일" -_timelineTutorial: - title: "Misskey의 사용 방법" - step1_1: "이것은 '타임라인'입니다. {name}에 게시된 '노트'가 시간 순서대로 표시됩니다." - step1_2: "타임라인은 몇 가지 종류로 나뉩니다. 그 중에 '홈 타임라인'은 내가 팔로우한 사람의 노트가 표시되며, '로컬 타임라인'에는 {name} 의 모든 노트가 표시됩니다." - step2_1: "그럼 시험삼아 노트를 작성해 봅시다. 화면에 있는 연필 버튼을 눌러 보세요." - step2_2: "첫 노트이니까 자기소개, 혹은 가볍게 \"안녕 {name}\"라고 올려 보는 건 어떨까요?" - step3_1: "노트 작성을 끝내셨나요?" - step3_2: "당신의 노트가 타임라인에 표시되어 있다면 성공입니다." - step4_1: "노트에는 '리액션'을 붙일 수 있습니다." - step4_2: "리액션을 붙이려면, 노트의 \"+\" 버튼을 클릭하고 원하는 이모지를 선택합니다." _2fa: alreadyRegistered: "이미 설정이 완료되었습니다." registerTOTP: "인증 앱 설정 시작" @@ -2014,6 +2004,9 @@ _notification: checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" notificationWillBeDisplayedLikeThis: "알림이 이렇게 표시됩니다" + reactedBySomeUsers: "{n}명이 반응했습니다" + renotedBySomeUsers: "{n}명이 리노트했습니다" + followedBySomeUsers: "{n}명에게 팔로우됨" _types: all: "전부" follow: "팔로잉" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 37af5321c5..42a29a5ad3 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -45,6 +45,7 @@ pin: "Vastmaken aan profielpagina" unpin: "Losmaken van profielpagina" copyContent: "Kopiëren inhoud" copyLink: "Kopiëren link" +copyLinkRenote: "" delete: "Verwijderen" deleteAndEdit: "Verwijderen en bewerken" deleteAndEditConfirm: "Weet je zeker dat je deze notitie wilt verwijderen en dan bewerken? Je verliest alle reacties, herdelingen en antwoorden erop." diff --git a/locales/no-NO.yml b/locales/no-NO.yml index d99c61c1dd..44944f8465 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -601,9 +601,6 @@ _time: minute: "Minutter" hour: "Timer" day: "Dager" -_timelineTutorial: - title: "Hvordan bruke Misskey" - step2_2: "Hva med å skrive en selvpresentasjon, eller bare \"Hei {name}!\" hvis du ikke har lyst?" _2fa: renewTOTPCancel: "Avbryt" _weekday: diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index a58a96534c..52db84911d 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1323,8 +1323,6 @@ _sfx: notification: "Notificações" _ago: invalid: "Não há nada aqui" -_timelineTutorial: - step1_2: "Existem vários tipos de linhas do tempo, por exemplo, na 'Linha do Tempo Principal', você verá as notas das pessoas que está seguindo, e na 'Linha do Tempo Local', verá todas as notas de {name}." _2fa: securityKeyInfo: "Além da autenticação por impressão digital ou PIN, você também pode configurar a autenticação por chaves de segurança de hardware compatível com FIDO2 para proteger ainda mais a sua conta." removeKeyConfirm: "Deseja excluir {name}?" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 606986203f..c0de0bdf59 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1587,16 +1587,6 @@ _time: minute: "мин" hour: "ч" day: "сут" -_timelineTutorial: - title: "Как пользоваться Misskey" - step1_1: "Это лицо Misskey, так называемая лента. Ваш инстанс, {name}, покажет тут все опубликованные на нём заметки в хронологическом порядке." - step1_2: "Здесь есть несколько лент. К примеру «персональная» лента отображает заметки тех, на кого вы подписаны. А «местная» — заметки тех, кого приютил {name}." - step2_1: "Что ж, теперь самое время опубликовать заметку. Если нажать вверху страницы на изображение карандаша, появится форма для текста." - step2_2: "Почему бы не написать немного о себе? Ну, или хотя бы «Привет, {name}»?" - step3_1: "Справились с первой заметкой?" - step3_2: "Отлично, теперь она должна появиться в вашей ленте." - step4_1: "А ещё здесь можно делиться своими реакциями на заметки." - step4_2: "Отмечайте реакции, нажимая на символ «+» под заметкой и выбирая значок по душе." _2fa: alreadyRegistered: "Двухфакторная аутентификация уже настроена." registerTOTP: "Начните настраивать приложение-аутентификатор" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 1313bb76cb..8df36a6829 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1154,7 +1154,6 @@ _initialAccountSetting: pushNotificationDescription: "กำลังเปิดใช้งานการแจ้งเตือนแบบพุชจะช่วยให้คุณได้รับการแจ้งเตือนจาก {name} โดยตรงบนอุปกรณ์ของคุณนะ" initialAccountSettingCompleted: "ตั้งค่าโปรไฟล์เสร็จสมบูรณ์แล้ว!" haveFun: "ขอให้สนุก {name}!" - ifYouNeedLearnMore: "ถ้าหากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับวิธีใช้ {ชื่อ} (Misskey) กรุณาไปที่ {link}" skipAreYouSure: "ต้องการข้ามการตั้งค่าโปรไฟล์จริงๆแบบนั้นหรอ?" laterAreYouSure: "ต้องการตั้งค่าโปรไฟล์ในภายหลังจริงๆอย่างงั้นหรอ?" _serverRules: @@ -1713,16 +1712,6 @@ _time: minute: "นาที" hour: "ชั่วโมง" day: "วัน" -_timelineTutorial: - title: "วิธีใช้งาน Misskey" - step1_1: "นี่คือ \"ไทม์ไลน์\" \"โน้ต\" ทั้งหมดที่ส่งใน {name} จะแสดงรายการตามลำดับเวลาที่นี่นะ" - step1_2: "อาจจะมีไทม์ไลน์ที่แตกต่างกันเล็กน้อยยกตัวอย่างเช่น \"ไทม์ไลน์หน้าแรก\" จะมีโน้ตของผู้ใช้ที่คุณติดตามและ \"ไทม์ไลน์ท้องถิ่น\" จะมีโน้ตจากผู้ใช้ทั้งหมดของ {name}" - step2_1: "มาลองโพสต์โน้ตต่อไปกัน คุณสามารถทำได้โดยการกดปุ่มที่มีไอคอนดินสอ" - step2_2: "ยังไงไหนลองเขียนแนะนำตัวเองหรือแค่ \"สวัสดี {name}!\" ถ้าคุณไม่รู้สึกเหมือนมัน?" - step3_1: "เสร็จสิ้นการโพสต์โน้ตย่อแรกของคุณแล้วอย่างงั้นหรอ?" - step3_2: "ไชโย! ตอนนี้โน้ตย่อแรกของคุณได้ปรากฏบนไทม์ไลน์ของคุณแล้วนะ" - step4_1: "คุณสามารถเพิ่ม \"การตอบสนอง\" ในโน้ตได้" - step4_2: "หากต้องการแนบการแสดงความรู้สึก ให้กดเครื่องหมาย \"+\" บนโน้ตแล้วเลือกอิโมจิที่คุณต้องการแสดงความรู้สึกที่ตนเองชอบได้เลย" _2fa: alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 7d650e016a..c816fc314b 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1067,7 +1067,6 @@ _initialAccountSetting: pushNotificationDescription: "Bật thông báo đẩy sẽ cho phép bạn nhận thông báo từ {name} trực tiếp từ thiết bị của bạn." initialAccountSettingCompleted: "Thiết lập tài khoản thành công!" haveFun: "Hãy tận hưởng {name} nhé!" - ifYouNeedLearnMore: "Nếu bạn muốn tìm hiểu thêm về cách sử dụng {name} (Misskey), hãy vào {link}." skipAreYouSure: "Bạn thực sự muốn bỏ qua mục thiết lập tài khoản?" laterAreYouSure: "Bạn thực sự muốn thiết lập tài khoản vào lúc khác?" _serverSettings: @@ -1503,9 +1502,6 @@ _time: minute: "phút" hour: "giờ" day: "ngày" -_timelineTutorial: - step4_1: "Bạn có thể thêm \"Reaction\" vào ghi chú" - step4_2: "Khi thêm biểu cảm hãy nhấn dấu \"+\"" _2fa: alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước." registerTOTP: "Đăng ký ứng dụng xác thực" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 646fd47f1f..76bc5c3271 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1153,7 +1153,6 @@ _initialAccountSetting: pushNotificationDescription: "启用推送通知的话,就可以在设备上接收来自 {name} 的通知了。" initialAccountSettingCompleted: "初始设定已经完成了!" haveFun: "希望 {name} 在这里玩得开心!" - ifYouNeedLearnMore: "关于 {name}(Misskey) 的使用方法,详见 {link}。" skipAreYouSure: "要跳过初始设置吗?" laterAreYouSure: "要稍后再进行初始设定吗?" _serverRules: @@ -1712,16 +1711,6 @@ _time: minute: "分" hour: "小时" day: "日" -_timelineTutorial: - title: "Misskey 的使用方法" - step1_1: "这个画面是「时间线」。{name}的投稿会按照帖子的发布时间顺序来显示。" - step1_2: "时间线有许多种类,比如在「首页时间线」中展现的是你关注的人的贴文;而在「本地时间线」中展现的是{name}里全部用户的贴文。" - step2_1: "那么接下来,试着写一些什么东西来发布吧!你可以通过点击屏幕上的铅笔图标来打开投稿页面。" - step2_2: "第一次发布的帖子内容,建议包含自我介绍,以及「开始使用{name}了」。" - step3_1: "将想说的话发出去了吗?" - step3_2: "太棒了!现在你可以在你的时间线中看到刚刚发布的帖子了。" - step4_1: "试着对帖子使用「回应」吧!" - step4_2: "在他人的帖子上按下「+」图标,即可选择想要的表情来进行「回应」。" _2fa: alreadyRegistered: "此设备已被注册" registerTOTP: "开始设置认证应用" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 08937b73a1..11d894900d 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -161,7 +161,7 @@ youCanCleanRemoteFilesCache: "按檔案管理的🗑️按鈕,可將快取全 cacheRemoteSensitiveFiles: "快取遠端的敏感檔案" cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取遠端的敏感檔案,而是直接連結。" flagAsBot: "此使用者是機器人" -flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Misskey內部系統將本帳戶識別為機器人" +flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整 Misskey 內部系統將本帳戶識別為機器人。" flagAsCat: "此帳戶是一隻貓,喵~~~!!!" flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" @@ -1031,7 +1031,7 @@ retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端使用者的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" showClipButtonInNoteFooter: "新增摘錄至貼文" -reactionsDisplaySize: "表情回應的顯示尺寸" +reactionsDisplaySize: "反應的顯示尺寸" noteIdOrUrl: "貼文ID或URL" video: "影片" videos: "影片" @@ -1126,7 +1126,7 @@ unnotifyNotes: "關閉貼文通知" authentication: "驗證" authenticationRequiredToContinue: "請於繼續前完成驗證" dateAndTime: "日期與時間" -showRenotes: "顯示轉發貼文" +showRenotes: "顯示其他人的轉發貼文" edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" @@ -1154,6 +1154,8 @@ releaseToRefresh: "放開以更新內容" refreshing: "載入更新中" pullDownToRefresh: "往下拉來更新內容" disableStreamingTimeline: "停用時間軸的即時更新" +useGroupedNotifications: "分組顯示通知訊息" +cwNotationRequired: "如果開啟「隱藏內容」,則需要註解說明。" _announcement: forExistingUsers: "僅限既有的使用者" forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。" @@ -1175,7 +1177,6 @@ _initialAccountSetting: pushNotificationDescription: "啟用推送通知,就可以在設備上接收{name}的通知。" initialAccountSettingCompleted: "初始設定完成了!" haveFun: "盡情享受{name}吧!" - ifYouNeedLearnMore: "請瀏覽{link}以更瞭解{name}(Misskey)的使用方法。" skipAreYouSure: "要略過初始設定嗎?" laterAreYouSure: "稍後再重新進行初始設定嗎?" _serverRules: @@ -1742,16 +1743,6 @@ _time: minute: "分鐘" hour: "小時" day: "日" -_timelineTutorial: - title: "Misskey 的使用方法" - step1_1: "這個畫面是「時間軸」。發佈到{name}的「貼文」會按照時間順序顯示。" - step1_2: "時間軸有多種類型,例如「首頁時間軸」是您追蹤帳戶的貼文、「本地時間軸」是{name}內所有帳戶的貼文。" - step2_1: "不如現在就嘗試發文吧!按鉛筆圖示的按鈕開啟發文頁面。" - step2_2: "您可以在第一篇貼文裡寫自我介紹,或是「我來到 {name} 了」之類的話。" - step3_1: "貼文發出去了嗎?" - step3_2: "如果您的貼文出現在時間軸上,就代表發文成功。" - step4_1: "可以對貼文標記「反應」。" - step4_2: "點擊貼文的「+」圖示,即可選擇表情符號來反應。" _2fa: alreadyRegistered: "此裝置已被註冊過了" registerTOTP: "開始設定驗證應用程式" @@ -2061,6 +2052,9 @@ _notification: checkNotificationBehavior: "確認通知的顯示行為" sendTestNotification: "發送測試通知" notificationWillBeDisplayedLikeThis: "通知會以這樣的方式顯示" + reactedBySomeUsers: "{n}人做出了反應" + renotedBySomeUsers: "{n}人做了轉發" + followedBySomeUsers: "被{n}人追隨了" _types: all: "全部 " note: "使用者的最新貼文" diff --git a/packages/backend/migration/1698840138000-add-allow-renote-to-external.js b/packages/backend/migration/1698840138000-add-allow-renote-to-external.js new file mode 100644 index 0000000000..0edf298841 --- /dev/null +++ b/packages/backend/migration/1698840138000-add-allow-renote-to-external.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAllowRenoteToExternal1698840138000 { + name = 'AddAllowRenoteToExternal1698840138000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "allowRenoteToExternal" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "allowRenoteToExternal"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 244085fefa..5a05030855 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -72,9 +72,9 @@ "@fastify/multipart": "8.0.0", "@fastify/static": "6.12.0", "@fastify/view": "8.2.0", - "@nestjs/common": "10.2.7", - "@nestjs/core": "10.2.7", - "@nestjs/testing": "10.2.7", + "@nestjs/common": "10.2.8", + "@nestjs/core": "10.2.8", + "@nestjs/testing": "10.2.8", "@peertube/http-signature": "1.7.0", "@simplewebauthn/server": "8.3.5", "@sinonjs/fake-timers": "11.2.2", @@ -88,7 +88,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.2", - "bullmq": "4.12.7", + "bullmq": "4.12.8", "cacheable-lookup": "7.0.0", "cbor": "9.0.1", "chalk": "5.3.0", @@ -102,7 +102,7 @@ "fastify-multer": "^2.0.3", "fastify": "4.24.3", "feed": "4.2.2", - "file-type": "18.5.0", + "file-type": "18.6.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "13.0.0", diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 1b8718335b..88fc033859 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -86,6 +86,7 @@ export const ACHIEVEMENT_TYPES = [ 'cookieClicked', 'brainDiver', 'smashTestNotificationButton', + 'tutorialCompleted', ] as const; @Injectable() diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 47091af216..c675c40295 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -65,6 +65,7 @@ import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FunoutTimelineService } from './FunoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { RegistryApiService } from './RegistryApiService.js'; import { ChartLoggerService } from './chart/ChartLoggerService.js'; import FederationChart from './chart/charts/federation.js'; import NotesChart from './chart/charts/notes.js'; @@ -197,6 +198,7 @@ const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipServic const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FunoutTimelineService: Provider = { provide: 'FunoutTimelineService', useExisting: FunoutTimelineService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService }; const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart }; @@ -333,6 +335,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FeaturedService, FunoutTimelineService, ChannelFollowingService, + RegistryApiService, ChartLoggerService, FederationChart, NotesChart, @@ -462,6 +465,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FeaturedService, $FunoutTimelineService, $ChannelFollowingService, + $RegistryApiService, $ChartLoggerService, $FederationChart, $NotesChart, @@ -592,6 +596,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FeaturedService, FunoutTimelineService, ChannelFollowingService, + RegistryApiService, FederationChart, NotesChart, UsersChart, @@ -720,6 +725,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FeaturedService, $FunoutTimelineService, $ChannelFollowingService, + $RegistryApiService, $FederationChart, $NotesChart, $UsersChart, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 34c8d4f8b1..40d1db600d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -100,17 +100,14 @@ class NotificationManager { } @bindThis - public async deliver() { + public async notify() { for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await this.mutingsRepository.findBy({ - muterId: x.target, - }); - - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); - - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + if (x.reason === 'renote') { + this.notificationService.createNotification(x.target, 'renote', { + noteId: this.note.id, + targetNoteId: this.note.renoteId!, + }, this.notifier.id); + } else { this.notificationService.createNotification(x.target, x.reason, { noteId: this.note.id, }, this.notifier.id); @@ -656,7 +653,7 @@ export class NoteCreateService implements OnApplicationShutdown { } } - nm.deliver(); + nm.notify(); //#region AP deliver if (this.userEntityService.isLocalUser(user)) { diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 3cbf975e56..e88ab053e6 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -97,17 +97,14 @@ class NotificationManager { } @bindThis - public async deliver() { + public async notify() { for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await this.mutingsRepository.findBy({ - muterId: x.target, - }); - - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); - - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + if (x.reason === 'renote') { + this.notificationService.createNotification(x.target, 'renote', { + noteId: this.note.id, + targetNoteId: this.note.renoteId!, + }, this.notifier.id); + } else { this.notificationService.createNotification(x.target, x.reason, { noteId: this.note.id, }, this.notifier.id); @@ -630,7 +627,7 @@ export class NoteEditService implements OnApplicationShutdown { } } - nm.deliver(); + nm.notify(); //#region AP deliver if (this.userEntityService.isLocalUser(user)) { diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 7c3672c67a..ad7be83e5b 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -19,6 +19,7 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { UserListService } from '@/core/UserListService.js'; +import type { FilterUnionByProperty } from '@/types.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { @@ -73,10 +74,10 @@ export class NotificationService implements OnApplicationShutdown { } @bindThis - public async createNotification( + public async createNotification( notifieeId: MiUser['id'], - type: MiNotification['type'], - data: Omit, 'notifierId'>, + type: T, + data: Omit, 'type' | 'id' | 'createdAt' | 'notifierId'>, notifierId?: MiUser['id'] | null, ): Promise { const profile = await this.cacheService.userProfileCache.fetch(notifieeId); @@ -128,9 +129,11 @@ export class NotificationService implements OnApplicationShutdown { id: this.idService.gen(), createdAt: new Date(), type: type, - notifierId: notifierId, + ...(notifierId ? { + notifierId, + } : {}), ...data, - } as MiNotification; + } as any as FilterUnionByProperty; const redisIdPromise = this.redisClient.xadd( `notificationTimeline:${notifieeId}`, diff --git a/packages/backend/src/core/RegistryApiService.ts b/packages/backend/src/core/RegistryApiService.ts new file mode 100644 index 0000000000..d340c5e480 --- /dev/null +++ b/packages/backend/src/core/RegistryApiService.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiRegistryItem, RegistryItemsRepository } from '@/models/_.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { MiUser } from '@/models/User.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class RegistryApiService { + constructor( + @Inject(DI.registryItemsRepository) + private registryItemsRepository: RegistryItemsRepository, + + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + } + + @bindThis + public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) { + // TODO: 作成できるキーの数を制限する + + const query = this.registryItemsRepository.createQueryBuilder('item'); + if (domain) { + query.where('item.domain = :domain', { domain: domain }); + } else { + query.where('item.domain IS NULL'); + } + query.andWhere('item.userId = :userId', { userId: userId }); + query.andWhere('item.key = :key', { key: key }); + query.andWhere('item.scope = :scope', { scope: scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await this.registryItemsRepository.update(existingItem.id, { + updatedAt: new Date(), + value: value, + }); + } else { + await this.registryItemsRepository.insert({ + id: this.idService.gen(), + updatedAt: new Date(), + userId: userId, + domain: domain, + scope: scope, + key: key, + value: value, + }); + } + + if (domain == null) { + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + this.globalEventService.publishMainStream(userId, 'registryUpdated', { + scope: scope, + key: key, + value: value, + }); + } + } + + @bindThis + public async getItem(userId: MiUser['id'], domain: string | null, scope: string[], key: string): Promise { + const query = this.registryItemsRepository.createQueryBuilder('item') + .where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }) + .andWhere('item.userId = :userId', { userId: userId }) + .andWhere('item.key = :key', { key: key }) + .andWhere('item.scope = :scope', { scope: scope }); + + const item = await query.getOne(); + + return item; + } + + @bindThis + public async getAllItemsOfScope(userId: MiUser['id'], domain: string | null, scope: string[]): Promise { + const query = this.registryItemsRepository.createQueryBuilder('item'); + query.where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }); + query.andWhere('item.userId = :userId', { userId: userId }); + query.andWhere('item.scope = :scope', { scope: scope }); + + const items = await query.getMany(); + + return items; + } + + @bindThis + public async getAllKeysOfScope(userId: MiUser['id'], domain: string | null, scope: string[]): Promise { + const query = this.registryItemsRepository.createQueryBuilder('item'); + query.select('item.key'); + query.where(domain == null ? 'item.domain IS NULL' : 'item.domain = :domain', { domain: domain }); + query.andWhere('item.userId = :userId', { userId: userId }); + query.andWhere('item.scope = :scope', { scope: scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); + } + + @bindThis + public async getAllScopeAndDomains(userId: MiUser['id']): Promise<{ domain: string | null; scopes: string[][] }[]> { + const query = this.registryItemsRepository.createQueryBuilder('item') + .select(['item.scope', 'item.domain']) + .where('item.userId = :userId', { userId: userId }); + + const items = await query.getMany(); + + const res = [] as { domain: string | null; scopes: string[][] }[]; + + for (const item of items) { + const target = res.find(x => x.domain === item.domain); + if (target) { + if (target.scopes.some(scope => scope.join('.') === item.scope.join('.'))) continue; + target.scopes.push(item.scope); + } else { + res.push({ + domain: item.domain, + scopes: [item.scope], + }); + } + } + + return res; + } + + @bindThis + public async remove(userId: MiUser['id'], domain: string | null, scope: string[], key: string) { + const query = this.registryItemsRepository.createQueryBuilder().delete(); + if (domain) { + query.where('domain = :domain', { domain: domain }); + } else { + query.where('domain IS NULL'); + } + query.andWhere('userId = :userId', { userId: userId }); + query.andWhere('key = :key', { key: key }); + query.andWhere('scope = :scope', { scope: scope }); + + await query.execute(); + } +} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 4d7e14f683..bd7f298021 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -509,7 +509,6 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { - followRequestId: followRequest.id, }, follower.id); } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 9e66834dfa..305946b8a6 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -85,6 +85,7 @@ export class ChannelEntityService { usersCount: channel.usersCount, notesCount: channel.notesCount, isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, ...(me ? { isFollowing, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index ec8e818be4..fee96bb80d 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -368,6 +368,7 @@ export class NoteEntityService implements OnModuleInit { name: channel.name, color: channel.color, isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, } : undefined, mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 9542815bd7..f74594ff0c 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -9,18 +9,19 @@ import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiNotification } from '@/models/Notification.js'; +import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; -import { notificationTypes } from '@/types.js'; +import { FilterUnionByProperty, notificationTypes } from '@/types.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); +const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']); @Injectable() export class NotificationEntityService implements OnModuleInit { @@ -66,17 +67,17 @@ export class NotificationEntityService implements OnModuleInit { }, ): Promise> { const notification = src; - const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( + const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( hint?.packedNotes != null ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId!, { id: meId }, { + : this.noteEntityService.pack(notification.noteId, { id: meId }, { detail: true, }) ) : undefined; - const userIfNeed = notification.notifierId != null ? ( + const userIfNeed = 'notifierId' in notification ? ( hint?.packedUsers != null ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + : this.userEntityService.pack(notification.notifierId, { id: meId }, { detail: false, }) ) : undefined; @@ -85,7 +86,7 @@ export class NotificationEntityService implements OnModuleInit { id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - userId: notification.notifierId, + userId: 'notifierId' in notification ? notification.notifierId : undefined, ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { @@ -111,7 +112,7 @@ export class NotificationEntityService implements OnModuleInit { let validNotifications = notifications; - const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], @@ -121,9 +122,9 @@ export class NotificationEntityService implements OnModuleInit { }); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); + validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); - const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); + const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull); const users = userIds.length > 0 ? await this.usersRepository.find({ where: { id: In(userIds) }, }) : []; @@ -133,10 +134,10 @@ export class NotificationEntityService implements OnModuleInit { const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest'); + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); if (followRequestNotifications.length > 0) { const reqs = await this.followRequestsRepository.find({ - where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) }, + where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, }); validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); } @@ -146,4 +147,141 @@ export class NotificationEntityService implements OnModuleInit { packedUsers, }))); } + + @bindThis + public async packGrouped( + src: MiGroupedNotification, + meId: MiUser['id'], + // eslint-disable-next-line @typescript-eslint/ban-types + options: { + + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; + }, + ): Promise> { + const notification = src; + const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId, { id: meId }, { + detail: true, + }) + ) : undefined; + const userIfNeed = 'notifierId' in notification ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId, { id: meId }, { + detail: false, + }) + ) : undefined; + + if (notification.type === 'reaction:grouped') { + const reactions = await Promise.all(notification.reactions.map(async reaction => { + const user = hint?.packedUsers != null + ? hint.packedUsers.get(reaction.userId)! + : await this.userEntityService.pack(reaction.userId, { id: meId }, { + detail: false, + }); + return { + user, + reaction: reaction.reaction, + }; + })); + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + note: noteIfNeed, + reactions, + }); + } else if (notification.type === 'renote:grouped') { + const users = await Promise.all(notification.userIds.map(userId => { + const user = hint?.packedUsers != null + ? hint.packedUsers.get(userId) + : this.userEntityService.pack(userId!, { id: meId }, { + detail: false, + }); + return user; + })); + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + note: noteIfNeed, + users, + }); + } + + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + userId: 'notifierId' in notification ? notification.notifierId : undefined, + ...(userIfNeed != null ? { user: userIfNeed } : {}), + ...(noteIfNeed != null ? { note: noteIfNeed } : {}), + ...(notification.type === 'reaction' ? { + reaction: notification.reaction, + } : {}), + ...(notification.type === 'achievementEarned' ? { + achievement: notification.achievement, + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader, + icon: notification.customIcon, + } : {}), + }); + } + + @bindThis + public async packGroupedMany( + notifications: MiGroupedNotification[], + meId: MiUser['id'], + ) { + if (notifications.length === 0) return []; + + let validNotifications = notifications; + + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); + const notes = noteIds.length > 0 ? await this.notesRepository.find({ + where: { id: In(noteIds) }, + relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], + }) : []; + const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { + detail: true, + }); + const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); + + validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); + + const userIds = []; + for (const notification of validNotifications) { + if ('notifierId' in notification) userIds.push(notification.notifierId); + if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId)); + if (notification.type === 'renote:grouped') userIds.push(...notification.userIds); + } + const users = userIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(userIds) }, + }) : []; + const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { + detail: false, + }); + const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + + // 既に解決されたフォローリクエストの通知を除外 + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); + if (followRequestNotifications.length > 0) { + const reqs = await this.followRequestsRepository.find({ + where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); + } + + return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, { + packedNotes, + packedUsers, + }))); + } } diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index f90f8c03d8..a7f9e262b1 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -93,4 +93,9 @@ export class MiChannel { default: false, }) public isSensitive: boolean; + + @Column('boolean', { + default: true, + }) + public allowRenoteToExternal: boolean; } diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index c0a9df2e23..1d5fc124e2 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -10,30 +10,73 @@ import { MiFollowRequest } from './FollowRequest.js'; import { MiAccessToken } from './AccessToken.js'; export type MiNotification = { + type: 'note'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'follow'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'mention'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'reply'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'renote'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; + targetNoteId: MiNote['id']; +} | { + type: 'quote'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'reaction'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; + reaction: string; +} | { + type: 'pollEnded'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'receiveFollowRequest'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'followRequestAccepted'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'achievementEarned'; + id: string; + createdAt: string; + achievement: string; +} | { + type: 'app'; id: string; - - // RedisのためDateではなくstring createdAt: string; - - /** - * 通知の送信者(initiator) - */ - notifierId: MiUser['id'] | null; - - /** - * 通知の種類。 - */ - type: typeof notificationTypes[number]; - - noteId: MiNote['id'] | null; - - followRequestId: MiFollowRequest['id'] | null; - - reaction: string | null; - - choice: number | null; - - achievement: string | null; /** * アプリ通知のbody @@ -56,4 +99,25 @@ export type MiNotification = { * アプリ通知のアプリ(のトークン) */ appAccessTokenId: MiAccessToken['id'] | null; -} +} | { + type: 'test'; + id: string; + createdAt: string; +}; + +export type MiGroupedNotification = MiNotification | { + type: 'reaction:grouped'; + id: string; + createdAt: string; + noteId: MiNote['id']; + reactions: { + userId: string; + reaction: string; + }[]; +} | { + type: 'renote:grouped'; + id: string; + createdAt: string; + noteId: MiNote['id']; + userIds: string[]; +}; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index f1019d1461..8f9770cdc5 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -76,5 +76,9 @@ export const packedChannelSchema = { type: 'boolean', optional: false, nullable: false, }, + allowRenoteToExternal: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 2c434913da..27db3bb62c 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -12,7 +12,6 @@ export const packedNotificationSchema = { type: 'string', optional: false, nullable: false, format: 'id', - example: 'xxxxxxxxxx', }, createdAt: { type: 'string', @@ -22,7 +21,7 @@ export const packedNotificationSchema = { type: { type: 'string', optional: false, nullable: false, - enum: [...notificationTypes], + enum: [...notificationTypes, 'reaction:grouped', 'renote:grouped'], }, user: { type: 'object', @@ -63,5 +62,33 @@ export const packedNotificationSchema = { type: 'string', optional: true, nullable: true, }, + reactions: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'object', + properties: { + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + required: ['user', 'reaction'], + }, + }, + }, + users: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index c458a8fd36..fde35ffd32 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -222,6 +222,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; @@ -235,7 +236,7 @@ import * as ep___i_registry_get from './endpoints/i/registry/get.js'; import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopes from './endpoints/i/registry/scopes.js'; +import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; @@ -588,6 +589,7 @@ const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep_ const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; +const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; @@ -601,7 +603,7 @@ const $i_registry_get: Provider = { provide: 'ep:i/registry/get', useClass: ep__ const $i_registry_keysWithType: Provider = { provide: 'ep:i/registry/keys-with-type', useClass: ep___i_registry_keysWithType.default }; const $i_registry_keys: Provider = { provide: 'ep:i/registry/keys', useClass: ep___i_registry_keys.default }; const $i_registry_remove: Provider = { provide: 'ep:i/registry/remove', useClass: ep___i_registry_remove.default }; -const $i_registry_scopes: Provider = { provide: 'ep:i/registry/scopes', useClass: ep___i_registry_scopes.default }; +const $i_registry_scopesWithDomain: Provider = { provide: 'ep:i/registry/scopes-with-domain', useClass: ep___i_registry_scopesWithDomain.default }; const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; @@ -958,6 +960,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $i_importUserLists, $i_importAntennas, $i_notifications, + $i_notificationsGrouped, $i_pageLikes, $i_pages, $i_pin, @@ -971,7 +974,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $i_registry_keysWithType, $i_registry_keys, $i_registry_remove, - $i_registry_scopes, + $i_registry_scopesWithDomain, $i_registry_set, $i_revokeToken, $i_signinHistory, @@ -1322,6 +1325,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $i_importUserLists, $i_importAntennas, $i_notifications, + $i_notificationsGrouped, $i_pageLikes, $i_pages, $i_pin, @@ -1335,7 +1339,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de $i_registry_keysWithType, $i_registry_keys, $i_registry_remove, - $i_registry_scopes, + $i_registry_scopesWithDomain, $i_registry_set, $i_revokeToken, $i_signinHistory, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index d3ece10859..f0b3961f94 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -149,7 +149,20 @@ export class SignupApiService { return; } - if (ticket.usedAt) { + // メアド認証が有効の場合 + if (instance.emailRequiredForSignup) { + // メアド認証済みならエラー + if (ticket.usedBy) { + reply.code(400); + return; + } + + // 認証しておらず、メール送信から30分以内ならエラー + if (ticket.usedAt && ticket.usedAt.getTime() + (1000 * 60 * 30) > Date.now()) { + reply.code(400); + return; + } + } else if (ticket.usedAt) { reply.code(400); return; } @@ -273,6 +286,10 @@ export class SignupApiService { try { const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); + if (this.idService.parse(pendingUser.id).date.getTime() + (1000 * 60 * 30) < Date.now()) { + throw new FastifyReplyError(400, 'EXPIRED'); + } + const { account, secret } = await this.signupService.signup({ username: pendingUser.username, passwordHash: pendingUser.password, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index cc87bfa539..c1cabd33e9 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -222,6 +222,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; @@ -235,7 +236,7 @@ import * as ep___i_registry_get from './endpoints/i/registry/get.js'; import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopes from './endpoints/i/registry/scopes.js'; +import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; @@ -586,6 +587,7 @@ const eps = [ ['i/import-user-lists', ep___i_importUserLists], ['i/import-antennas', ep___i_importAntennas], ['i/notifications', ep___i_notifications], + ['i/notifications-grouped', ep___i_notificationsGrouped], ['i/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], ['i/pin', ep___i_pin], @@ -599,7 +601,7 @@ const eps = [ ['i/registry/keys-with-type', ep___i_registry_keysWithType], ['i/registry/keys', ep___i_registry_keys], ['i/registry/remove', ep___i_registry_remove], - ['i/registry/scopes', ep___i_registry_scopes], + ['i/registry/scopes-with-domain', ep___i_registry_scopesWithDomain], ['i/registry/set', ep___i_registry_set], ['i/revoke-token', ep___i_revokeToken], ['i/signin-history', ep___i_signinHistory], diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index 3ba411d28c..3dd1eddd01 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -50,6 +50,7 @@ export const paramDef = { bannerId: { type: 'string', format: 'misskey:id', nullable: true }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, + allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['name'], } as const; @@ -87,6 +88,7 @@ export default class extends Endpoint { // eslint- bannerId: banner ? banner.id : null, isSensitive: ps.isSensitive ?? false, ...(ps.color !== undefined ? { color: ps.color } : {}), + allowRenoteToExternal: ps.allowRenoteToExternal ?? true, } as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index ab69f62a7b..93d02e4a12 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -61,6 +61,7 @@ export const paramDef = { }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, + allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['channelId'], } as const; @@ -115,6 +116,7 @@ export default class extends Endpoint { // eslint- ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(banner ? { bannerId: banner.id } : {}), ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), + ...(typeof ps.allowRenoteToExternal === 'boolean' ? { allowRenoteToExternal: ps.allowRenoteToExternal } : {}), }); return await this.channelEntityService.pack(channel.id, me); diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts new file mode 100644 index 0000000000..4ea94b07f6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Brackets, In } from 'typeorm'; +import * as Redis from 'ioredis'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/_.js'; +import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; + +export const meta = { + tags: ['account', 'notifications'], + + requireCredential: true, + + limit: { + duration: 30000, + max: 30, + }, + + kind: 'read:notifications', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Notification', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + markAsRead: { type: 'boolean', default: true }, + // 後方互換のため、廃止された通知タイプも受け付ける + includeTypes: { type: 'array', items: { + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + } }, + excludeTypes: { type: 'array', items: { + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + } }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private idService: IdService, + private notificationEntityService: NotificationEntityService, + private notificationService: NotificationService, + private noteReadService: NoteReadService, + ) { + super(meta, paramDef, async (ps, me) => { + const EXTRA_LIMIT = 100; + + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + + const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + + const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', + 'COUNT', limit); + + if (notificationsRes.length === 0) { + return []; + } + + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length === 0) { + return []; + } + + // Mark all as read + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); + } + + // grouping + let groupedNotifications = [notifications[0]] as MiGroupedNotification[]; + for (let i = 1; i < notifications.length; i++) { + const notification = notifications[i]; + const prev = notifications[i - 1]; + let prevGroupedNotification = groupedNotifications.at(-1)!; + + if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) { + if (prevGroupedNotification.type !== 'reaction:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'reaction:grouped', + id: '', + createdAt: prev.createdAt, + noteId: prev.noteId!, + reactions: [{ + userId: prev.notifierId!, + reaction: prev.reaction!, + }], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + (prevGroupedNotification as FilterUnionByProperty).reactions.push({ + userId: notification.notifierId!, + reaction: notification.reaction!, + }); + prevGroupedNotification.id = notification.id; + continue; + } + if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) { + if (prevGroupedNotification.type !== 'renote:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'renote:grouped', + id: '', + createdAt: notification.createdAt, + noteId: prev.noteId!, + userIds: [prev.notifierId!], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + (prevGroupedNotification as FilterUnionByProperty).userIds.push(notification.notifierId!); + prevGroupedNotification.id = notification.id; + continue; + } + + groupedNotifications.push(notification); + } + + groupedNotifications = groupedNotifications.slice(0, ps.limit); + + const noteIds = groupedNotifications + .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); + + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); + this.noteReadService.read(me.id, notes); + } + + return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 91dd72e805..039fd9454c 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -7,7 +7,7 @@ import { Brackets, In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; @@ -113,8 +113,8 @@ export default class extends Endpoint { // eslint- } const noteIds = notifications - .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId!); + .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId); if (noteIds.length > 0) { const notes = await this.notesRepository.findBy({ id: In(noteIds) }); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts index 211e6637dc..29fa0a29cc 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -5,13 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -20,23 +17,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: [], + required: ['scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const items = await query.getMany(); + super(meta, paramDef, async (ps, me, accessToken) => { + const items = await this.registryApiService.getAllItemsOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); const res = {} as Record; diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts index 9c6f2d6781..5b460b45d6 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -5,15 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, - secure: true, - errors: { noSuchKey: { message: 'No such key.', @@ -30,24 +27,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key'], + required: ['key', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); + super(meta, paramDef, async (ps, me, accessToken) => { + const item = await this.registryApiService.getItem(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); if (item == null) { throw new ApiError(meta.errors.noSuchKey); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts index 729e729b8c..e8c28298ef 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -5,15 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, - secure: true, - errors: { noSuchKey: { message: 'No such key.', @@ -30,24 +27,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key'], + required: ['key', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); + super(meta, paramDef, async (ps, me, accessToken) => { + const item = await this.registryApiService.getItem(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); if (item == null) { throw new ApiError(meta.errors.noSuchKey); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts index ffd2860fde..8953ee5d3d 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -5,13 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -20,36 +17,31 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: [], + required: ['scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const items = await query.getMany(); + super(meta, paramDef, async (ps, me, accessToken) => { + const items = await this.registryApiService.getAllItemsOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); const res = {} as Record; for (const item of items) { const type = typeof item.value; res[item.key] = - item.value === null ? 'null' : - Array.isArray(item.value) ? 'array' : - type === 'number' ? 'number' : - type === 'string' ? 'string' : - type === 'boolean' ? 'boolean' : - type === 'object' ? 'object' : - null as never; + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; } return res; diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts index 7239bb66e1..04e120d752 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/keys.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -5,13 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -20,26 +17,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: [], + required: ['scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .select('item.key') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const items = await query.getMany(); - - return items.map(x => x.key); + super(meta, paramDef, async (ps, me, accessToken) => { + return await this.registryApiService.getAllKeysOfScope(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts index ae687fefe9..ba8100b547 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/remove.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -7,13 +7,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { RegistryItemsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; import { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, - secure: true, - errors: { noSuchKey: { message: 'No such key.', @@ -30,30 +29,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key'], + required: ['key', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const item = await query.getOne(); - - if (item == null) { - throw new ApiError(meta.errors.noSuchKey); - } - - await this.registryItemsRepository.remove(item); + super(meta, paramDef, async (ps, me, accessToken) => { + await this.registryApiService.remove(me.id, accessToken != null ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts new file mode 100644 index 0000000000..1ff994b82c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes-with-domain.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; + +export const meta = { + requireCredential: true, + secure: true, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private registryApiService: RegistryApiService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.registryApiService.getAllScopeAndDomains(me.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts deleted file mode 100644 index 7637cdcf73..0000000000 --- a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - requireCredential: true, - - secure: true, -} as const; - -export const paramDef = { - type: 'object', - properties: {}, - required: [], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, - ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .select('item.scope') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }); - - const items = await query.getMany(); - - const res = [] as string[][]; - - for (const item of items) { - if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; - res.push(item.scope); - } - - return res; - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts index 6203e7aa8b..58bb450bce 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/set.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -5,15 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { RegistryItemsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; +import { RegistryApiService } from '@/core/RegistryApiService.js'; export const meta = { requireCredential: true, - - secure: true, } as const; export const paramDef = { @@ -24,51 +19,18 @@ export const paramDef = { scope: { type: 'array', default: [], items: { type: 'string', pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), } }, + domain: { type: 'string', nullable: true }, }, - required: ['key', 'value'], + required: ['key', 'value', 'scope'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.registryItemsRepository) - private registryItemsRepository: RegistryItemsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, + private registryApiService: RegistryApiService, ) { - super(meta, paramDef, async (ps, me) => { - const query = this.registryItemsRepository.createQueryBuilder('item') - .where('item.domain IS NULL') - .andWhere('item.userId = :userId', { userId: me.id }) - .andWhere('item.key = :key', { key: ps.key }) - .andWhere('item.scope = :scope', { scope: ps.scope }); - - const existingItem = await query.getOne(); - - if (existingItem) { - await this.registryItemsRepository.update(existingItem.id, { - updatedAt: new Date(), - value: ps.value, - }); - } else { - await this.registryItemsRepository.insert({ - id: this.idService.gen(), - updatedAt: new Date(), - userId: me.id, - domain: null, - scope: ps.scope, - key: ps.key, - value: ps.value, - }); - } - - // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする - this.globalEventService.publishMainStream(me.id, 'registryUpdated', { - scope: ps.scope, - key: ps.key, - value: ps.value, - }); + super(meta, paramDef, async (ps, me, accessToken) => { + await this.registryApiService.set(me.id, accessToken ? accessToken.id : (ps.domain ?? null), ps.scope, ps.key, ps.value); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index ec2d5d6579..e161c47f9f 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -45,7 +45,7 @@ export const meta = { limit: { duration: ms('1hour'), - max: 10, + max: 20, }, errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts index bfb024bcf2..6086f99c92 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.test.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts @@ -64,7 +64,7 @@ describe('api:notes/create', () => { test('0 characters cw', () => { expect(v({ text: 'Body', cw: '' })) - .toBe(VALID); + .toBe(INVALID); }); test('reject only cw', () => { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 649068fb20..df02d3acb7 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -16,8 +16,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -99,6 +99,12 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, }, } as const; @@ -109,7 +115,7 @@ export const paramDef = { visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, - cw: { type: 'string', nullable: true, maxLength: 100 }, + cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, @@ -246,6 +252,19 @@ export default class extends Endpoint { // eslint- // specified / direct noteはreject throw new ApiError(meta.errors.cannotRenoteDueToVisibility); } + + if (renote.channelId && renote.channelId !== ps.channelId) { + // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック + // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する + const renoteChannel = await this.channelsRepository.findOneById(renote.channelId); + if (renoteChannel == null) { + // リノートしたいノートが書き込まれているチャンネルが無い + throw new ApiError(meta.errors.noSuchChannel); + } else if (!renoteChannel.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + } + } } let reply: MiNote | null = null; diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index 07f04bba5a..6140c80a5d 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -117,6 +117,12 @@ export const meta = { code: "NOT_LOCAL_USER", id: "b907f407-2aa0-4283-800b-a2c56290b822", }, + + cannotRenoteOutsideOfChannel: { + message: 'Cannot renote outside of channel.', + code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL', + id: '33510210-8452-094c-6227-4a6c05d99f00', + }, }, } as const; @@ -134,7 +140,7 @@ export const paramDef = { }, }, text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, - cw: { type: "string", nullable: true, maxLength: 250 }, + cw: { type: "string", nullable: true, minLength: 1, maxLength: 250 }, localOnly: { type: "boolean", default: false }, noExtractMentions: { type: "boolean", default: false }, noExtractHashtags: { type: "boolean", default: false }, @@ -281,6 +287,19 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.youHaveBeenBlocked); } } + + if (renote.channelId && renote.channelId !== ps.channelId) { + // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック + // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する + const renoteChannel = await this.channelsRepository.findOneById(renote.channelId); + if (renoteChannel == null) { + // リノートしたいノートが書き込まれているチャンネルが無い + throw new ApiError(meta.errors.noSuchChannel); + } else if (!renoteChannel.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + } + } } let reply: MiNote | null = null; diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 19bc6fa8d7..7c6a979160 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -42,8 +42,8 @@ export default class extends Endpoint { // eslint- this.notificationService.createNotification(user.id, 'app', { appAccessTokenId: token ? token.id : null, customBody: ps.body, - customHeader: ps.header ?? token?.name, - customIcon: ps.icon ?? token?.iconUrl, + customHeader: ps.header ?? token?.name ?? null, + customIcon: ps.icon ?? token?.iconUrl ?? null, }); }); } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 466a5bca5b..8c313bd153 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -253,8 +253,9 @@ export class ClientServerService { decorateReply: false, }); } else { + const port = (process.env.VITE_PORT ?? '5173'); fastify.register(fastifyProxy, { - upstream: 'http://localhost:5173', // TODO: port configuration + upstream: 'http://localhost:' + port, prefix: '/vite', rewritePrefix: '/vite', }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 979c053224..f88a1d4e56 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -255,3 +255,9 @@ export type Serialized = { ? Serialized : T[K]; }; + +export type FilterUnionByProperty< + Union, + Property extends string | number | symbol, + Condition +> = Union extends Record ? Union : never; diff --git a/packages/frontend/assets/tutorial/ai.webp b/packages/frontend/assets/tutorial/ai.webp new file mode 100644 index 0000000000..d9d4564942 Binary files /dev/null and b/packages/frontend/assets/tutorial/ai.webp differ diff --git a/packages/frontend/assets/tutorial/natto_failed.webp b/packages/frontend/assets/tutorial/natto_failed.webp new file mode 100644 index 0000000000..87db5f7732 Binary files /dev/null and b/packages/frontend/assets/tutorial/natto_failed.webp differ diff --git a/packages/frontend/assets/tutorial/timeline_tab.png b/packages/frontend/assets/tutorial/timeline_tab.png new file mode 100644 index 0000000000..b52ad5fb51 Binary files /dev/null and b/packages/frontend/assets/tutorial/timeline_tab.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 9a510f67fa..7ea815cd3c 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -71,7 +71,7 @@ "twemoji-parser": "14.0.0", "typescript": "5.2.2", "uuid": "9.0.1", - "v-code-diff": "1.7.1", + "v-code-diff": "1.7.2", "vanilla-tilt": "1.8.1", "vite": "4.5.0", "vue": "3.3.7", diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 2c08ac754e..9969c10258 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -51,6 +51,7 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { useRouter } from '@/router.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; +import { deviceKind } from '@/scripts/device-kind.js'; const router = useRouter(); @@ -78,7 +79,11 @@ function onClick(ev: MouseEvent) { if (props.selectMode) { emit('chosen', props.file); } else { - router.push(`/my/drive/file/${props.file.id}`); + if (deviceKind === 'desktop') { + router.push(`/my/drive/file/${props.file.id}`); + } else { + os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + } } } diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index 429d3a2996..6e643639f2 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -7,7 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
+
@@ -16,11 +17,23 @@ import { } from 'vue'; const props = defineProps<{ warn?: boolean; + closable?: boolean; }>(); + +const emit = defineEmits<{ + (ev: 'close'): void; +}>(); + +function close() { + // こいつの中では非表示動作は行わない + emit('close'); +} diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 0f03b38bc4..e267560db1 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -85,7 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ appearNote.channel.name }} - + @@ -111,7 +111,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -32,9 +34,10 @@ SPDX-License-Identifier: AGPL-3.0-only