diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6fa2cf4f..011e1cbccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,58 @@ --> +## 13.11.3 + +### General +- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 + - Deckのカラムとしても追加可能 +- カスタム絵文字関連の改善 + * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように + * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように +- カスタム絵文字でリアクションできないことがある問題を修正 + +### Client +- チャンネルのピン留めされたノートの順番が正しくない問題を修正 + +### Server +- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io +- Misskey Webでのサーバーサイドエラー画面を改善 +- Misskey Webでのサーバーサイドエラーのログが残るように +- ノート作成時のアンテナ追加パフォーマンスを改善 +- アンテナとロールTLのuntil/sinceプロパティが動くように + +## 13.11.2 + +### Note +- 13.11.0または13.11.1から13.11.2以降にアップデートする場合、Redisのカスタム絵文字のキャッシュを削除する必要があります(https://github.com/misskey-dev/misskey/issues/10502#issuecomment-1502790755 参照) + +### General +- チャンネルの検索用ページの追加 + +### Client +- 常に広告を見られるオプションを追加 +- ユーザーページの画像一覧が表示されない問題を修正 +- webhook, 連携アプリ一覧でコンテンツが重複して表示される問題を修正 +- iPhoneで絵文字ピッカーの表示が崩れる問題を修正 +- iPhoneでウィジェットドロワーの「ウィジェットを編集」が押しにくい問題を修正 +- 投稿フォームのデザインを調整 +- ギャラリーの人気の投稿が無限にページングされる問題を修正 + +### Server +- channels/search Endpoint APIの追加 +- APIパラメータサイズ上限を32kbから1mbに緩和 +- プッシュ通知送信時のパフォーマンスを改善 +- ローカルのカスタム絵文字のキャッシュが効いていなかった問題を修正 +- アンテナのノート、チャンネルのノート、通知が正常に作成できないことがある問題を修正 +- ストリーミングのLTLチャンネルでサーバー側にエラーログが出るのを修正 + +### Service Worker +- 「通知が既読になったらプッシュ通知を削除する」を復活 + * 「プッシュ通知が更新されました」の挙動を変えた(ホストとバージョンを表示するようにし、一定時間後の削除は行わないように) +- プッシュ通知が実績を解除 (achievementEarned) に対応 +- プッシュ通知のアクションから既存のクライアントの投稿フォームを開くことになった際の挙動を修正 +- たくさんのプッシュ通知を閉じた際、その通知の数だけnotifications/mark-all-as-readを叩くのをやめるように + ## 13.11.1 ### General diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fece05d7a9..b8a20c8078 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -245,7 +245,6 @@ You can override the default story by creating a impl story file (`MyComponent.s ```ts /* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-duplicates */ import { StoryObj } from '@storybook/vue3'; import MyComponent from './MyComponent.vue'; export const Default = { diff --git a/gulpfile.js b/gulpfile.js index a04ab4c1ad..6507aad60e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => { }); gulp.task('build:backend:style', () => { - return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css']) + return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css']) .pipe(cssnano({ zindex: false })) diff --git a/locales/de-DE.yml b/locales/de-DE.yml index ef1cea6e72..095c71642a 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -20,6 +20,7 @@ noNotes: "Keine Notizen gefunden" noNotifications: "Keine Benachrichtigungen gefunden" instance: "Instanz" settings: "Einstellungen" +notificationSettings: "Benachrichtigungseinstellungen" basicSettings: "Allgemeine Einstellungen" otherSettings: "Weitere Einstellungen" openInWindow: "In einem Fenster öffnen" @@ -991,6 +992,7 @@ largeNoteReactions: "Reaktionen vergrößert anzeigen" noteIdOrUrl: "Notiz-ID oder URL" accountMigration: "Konto-Umzug" accountMoved: "Dieser Benutzer ist zu einem neuen Konto umgezogen:" +forceShowAds: "Werbung immer anzeigen" _accountMigration: moveTo: "Dieses Konto zu einem neuen umziehen" moveToLabel: "Umzugsziel:" @@ -1406,6 +1408,8 @@ _channel: following: "Gefolgt" usersCount: "{n} Teilnehmer" notesCount: "{n} Notizen" + nameAndDescription: "Name und Beschreibung" + nameOnly: "Nur Name" _menuDisplay: sideFull: "Seitlich" sideIcon: "Seitlich (Icons)" @@ -1886,6 +1890,7 @@ _deck: channel: "Kanal" mentions: "Erwähnungen" direct: "Direktnachrichten" + roleTimeline: "Rollenchronik" _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" diff --git a/locales/en-US.yml b/locales/en-US.yml index 1137199739..e2407bd3e7 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -20,6 +20,7 @@ noNotes: "No notes" noNotifications: "No notifications" instance: "Instance" settings: "Settings" +notificationSettings: "Notification Settings" basicSettings: "Basic Settings" otherSettings: "Other Settings" openInWindow: "Open in window" @@ -67,7 +68,7 @@ import: "Import" export: "Export" files: "Files" download: "Download" -driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted." +driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? It will also vanish from all contents that use it." unfollowConfirm: "Are you sure you want to unfollow {name}?" exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed." importRequested: "You've requested an import. This may take a while." @@ -500,7 +501,7 @@ objectStoragePrefixDesc: "Files will be stored under directories with this prefi objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "Leave this empty if you are using AWS S3, otherwise specify the endpoint as '' or ':', depending on the service you are using." objectStorageRegion: "Region" -objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, leave this blank or enter 'us-east-1'." +objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, enter 'us-east-1'. Leave empty if using AWS configuration files or environment variables." objectStorageUseSSL: "Use SSL" objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections" objectStorageUseProxy: "Connect over Proxy" @@ -918,7 +919,7 @@ unsubscribePushNotification: "Disable push notifications" pushNotificationAlreadySubscribed: "Push notifications are already enabled" pushNotificationNotSupported: "Your browser or instance does not support push notifications" sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" -sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the battery usage of your device, if applicable." +sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the power consumption of your device." windowMaximize: "Maximize" windowMinimize: "Minimize" windowRestore: "Restore" @@ -991,6 +992,7 @@ largeNoteReactions: "Enlargen displayed reactions" noteIdOrUrl: "Note ID or URL" accountMigration: "Account Migration" accountMoved: "This user has moved to a new account:" +forceShowAds: "Always show ads" _accountMigration: moveTo: "Migrate this account to a different one" moveToLabel: "Account to move to:" @@ -1406,6 +1408,8 @@ _channel: following: "Followed" usersCount: "{n} Participants" notesCount: "{n} Notes" + nameAndDescription: "Name and description" + nameOnly: "Name only" _menuDisplay: sideFull: "Side" sideIcon: "Side (Icons)" @@ -1868,7 +1872,7 @@ _deck: swapRight: "Swap with the right column" swapUp: "Swap with the above column" swapDown: "Swap with the below column" - stackLeft: "Stack with the left column" + stackLeft: "Stack on left column" popRight: "Pop column to the right" profile: "Profile" newProfile: "New profile" @@ -1886,6 +1890,7 @@ _deck: channel: "Channel" mentions: "Mentions" direct: "Direct notes" + roleTimeline: "Role Timeline" _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 1272da9b3e..97c0b02fe5 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "Hapus tanda konten sensitif" enterFileName: "Masukkan nama berkas" mute: "Bisukan" unmute: "Hapus bisukan" +renoteMute: "Matikan renote" +renoteUnmute: "Batal mematikan renote" block: "Blokir" unblock: "Buka blokir" suspend: "Bekukan" @@ -393,11 +395,15 @@ about: "Informasi" aboutMisskey: "Tentang Misskey" administrator: "Admin" token: "Token" +totp: "Aplikasi autentikator" +totpDescription: "Gunakan aplikasi autentikator untuk mendapatkan kata sandi sekali pakai" moderator: "Moderator" moderation: "Moderasi" nUsersMentioned: "{n} pengguna disebut" +securityKeyAndPasskey: "Security key dan passkey" securityKey: "Kunci keamanan" lastUsed: "Terakhir digunakan" +lastUsedAt: "Penggunaan terakhir: {t}" unregister: "Batalkan pendaftaran" passwordLessLogin: "Setel login tanpa kata sandi" resetPassword: "Atur ulang kata sandi" @@ -844,6 +850,7 @@ tenMinutes: "10 Menit" oneHour: "1 Jam" oneDay: "1 Hari" oneWeek: "1 Bulan" +oneMonth: "satu bulan" reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan." failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun" rateLimitExceeded: "Batas sudah terlampaui" @@ -901,6 +908,7 @@ pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung pember sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca" sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu." windowMaximize: "Maksimalkan" +windowMinimize: "Minimalkan" windowRestore: "Kembalikan" caption: "Keterangan" loggedInAsBot: "Sedang login sebagai bot" @@ -939,6 +947,12 @@ collapseRenotes: "Tutup renote yang sudah kamu lihat" internalServerError: "Kesalahan internal peladen" internalServerErrorDescription: "Peladen sedang mengalami galat tak terduga" copyErrorInfo: "Salin detil galat" +joinThisServer: "Gabung server ini" +exploreOtherServers: "Cari server lain" +letsLookAtTimeline: "LIhat timeline" +disableFederationConfirm: "Matikan federasi?" +disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi privat. Umumnya, mematikan federasi tidak diperlukan." +disableFederationOk: "Matikan federasi" _achievements: earnedAt: "Terbuka pada" _types: diff --git a/locales/it-IT.yml b/locales/it-IT.yml index f9b65488bb..f43adcb35b 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -20,6 +20,7 @@ noNotes: "Nessuna nota!" noNotifications: "Nessuna notifica" instance: "Istanza" settings: "Impostazioni" +notificationSettings: "Preferenze di notifica" basicSettings: "Impostazioni generali" otherSettings: "Altre impostazioni" openInWindow: "Apri in una finestra" @@ -786,7 +787,7 @@ gallery: "Galleria" recentPosts: "Le più recenti" popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" -ads: "Pubblicità" +ads: "Banner" expiration: "Scadenza" startingperiod: "Periodo di inizio" memo: "Promemoria" @@ -991,6 +992,7 @@ largeNoteReactions: "Ingrandisci le reazioni" noteIdOrUrl: "ID della Nota o URL" accountMigration: "Migrazione del profilo" accountMoved: "Questo profilo ha migrato altrove:" +forceShowAds: "Mostra sempre i banner" _accountMigration: moveTo: "Migrare questo profilo verso un un altro" moveToLabel: "Profilo verso cui migrare" @@ -1406,6 +1408,8 @@ _channel: following: "Seguiti" usersCount: "{n} partecipanti" notesCount: "{n} note" + nameAndDescription: "Nome e descrizione" + nameOnly: "Solo il nome" _menuDisplay: sideFull: "Laterale" sideIcon: "Laterale (solo icone)" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 32bf47c209..092a4aed32 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -20,6 +20,7 @@ noNotes: "ノートはありません" noNotifications: "通知はありません" instance: "サーバー" settings: "設定" +notificationSettings: "通知の設定" basicSettings: "基本設定" otherSettings: "その他の設定" openInWindow: "ウィンドウで開く" @@ -917,8 +918,8 @@ subscribePushNotification: "プッシュ通知を有効化" unsubscribePushNotification: "プッシュ通知を停止する" pushNotificationAlreadySubscribed: "プッシュ通知は有効です" pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" -sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" -sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" +sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する" +sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "元に戻す" @@ -991,6 +992,7 @@ largeNoteReactions: "ノートのリアクションを大きく表示" noteIdOrUrl: "ノートIDまたはURL" accountMigration: "アカウントの引っ越し" accountMoved: "このユーザーは新しいアカウントに引っ越しました:" +forceShowAds: "常に広告を表示する" _accountMigration: moveTo: "このアカウントを新しいアカウントに引っ越す" @@ -1426,6 +1428,8 @@ _channel: following: "フォロー中" usersCount: "{n}人が参加中" notesCount: "{n}投稿があります" + nameAndDescription: "名前と説明" + nameOnly: "名前のみ" _menuDisplay: sideFull: "横" @@ -1939,6 +1943,7 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 81f99aace2..4117a25b0e 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -20,6 +20,7 @@ noNotes: "ノートはあらへん" noNotifications: "通知はあらへん" instance: "サーバー" settings: "設定" +notificationSettings: "通知の設定" basicSettings: "基本設定" otherSettings: "ほかの設定" openInWindow: "ウィンドウで開くで" @@ -991,6 +992,7 @@ largeNoteReactions: "ノートのリアクションを大きする" noteIdOrUrl: "ノートIDかURL" accountMigration: "アカウントのお引っ越し" accountMoved: "このユーザーはさらのアカウントに引っ越したで:" +forceShowAds: "常に広告を表示しとく" _accountMigration: moveTo: "このアカウントをさらのアカウントに引っ越すで" moveToLabel: "引っ越し先のアカウント:" @@ -1406,6 +1408,8 @@ _channel: following: "フォロー中やで" usersCount: "{n}人が参加中やで" notesCount: "{n}こ投稿があるで" + nameAndDescription: "名前と説明" + nameOnly: "名前だけ" _menuDisplay: sideFull: "横" sideIcon: "横(アイコン)" @@ -1886,6 +1890,7 @@ _deck: channel: "チャンネル" mentions: "あんた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" _dialog: charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 9c1a48c67c..3f97b3982c 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -163,11 +163,15 @@ instanceInfo: "ອີນສະແຕນ" statistics: "ສະຖິຕິ" clearQueue: "ລ້າງຄິວ" clearCachedFiles: "ລຶບລ້າງແຄສ" +noUsers: "ບໍ່ພົບຜູ້ໃຊ້" editProfile: "ແກ້ໄຂໂປຣໄຟລ໌" done: "ສຳເລັດ" processing: "ກຳລັງປະມວນຜົນ" preview: "ສະແດງເປັນຕົວຢ່າງ" default: "ຄ່າເລີ່ມຕົ້ນ" +defaultValueIs: "ຄ່າເລີ່ມຕົ້ນ: {value}" +noCustomEmojis: "ບໍ່ມີອີໂມຈິ" +noJobs: "ບໍ່ມີຊິ້ນວຽກ" federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " suspended: "ໂຈະ" @@ -182,6 +186,9 @@ changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" security: "ຄວາມປອດໄພ" retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" +newPassword: "ລະຫັດຜ່ານໃໝ່" +newPasswordRetype: "ໃສ່ລະຫັດຜ່ານໃໝ່ອີກເທື່ອໜຶ່ງ" +attachFile: "ແນບໄຟລ໌" more: "ເພີ່ມເຕີມ!" featured: "ໄຮໄລທ໌" usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" @@ -196,25 +203,31 @@ saved: "ບັນທຶກແລ້ວ" messaging: "ແຊ໋ດ" upload: "ອັບໂຫຼດ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" +fromDrive: "ຈາກ Drive" fromUrl: "ຈາກ URL" uploadFromUrl: "ອັບໂຫຼດຈາກ URL" uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" +uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດ" messageRead: "ອ່ານແລ້ວ" startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" nUsersRead: "ອ່ານໂດຍ {n}" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" +activity: "ກິດຈະກຳ" images: "ຮູບພາບ" birthday: "ວັນເກີດ" yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" location: "ທີ່ຕັ້ງ" theme: "ແທ໋ມ" +themeForLightMode: "ຮູບແບບສີສັນເພື່ອໃຊ້ໃນໂໝດແສງ" +themeForDarkMode: "ຮູບແບບສີສັນທີ່ຈະໃຊ້ຢູ່ໃນໂໝດມືດ" light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" +syncDeviceDarkMode: "ຊິງຄ໌ໂໝດມືດກັບການຕັ້ງຄ່າທົ່ວອຸປະກອນ" drive: "ຂັບ" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" @@ -265,6 +278,9 @@ invite: "ເຊີນ" driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" +turnstileSiteKey: "ກະແຈໄຊທ໌" +turnstileSecretKey: "ກະແຈລັບ" +name: "ຊື່" userList: "ລາຍການ" about: "ກ່ຽວກັບ" aboutMisskey: "ກ່ຽວກັບ Misskey" @@ -326,6 +342,7 @@ _widgets: instanceInfo: "ອີນສະແຕນ" notifications: "ການແຈ້ງເຕືອນ" timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" + activity: "ກິດຈະກຳ" federation: "ສະຫະພັນ" _userList: chooseList: "ເລືອກບັນຊີລາຍການ" @@ -335,6 +352,7 @@ _visibility: home: "ໜ້າຫຼັກ" followers: "ຜູ້ຕິດຕາມ" _profile: + name: "ຊື່" username: "ຊື່ຜູ້ໃຊ້" _exportOrImport: followingList: "ກຳລັງຕິດຕາມ" @@ -368,3 +386,5 @@ _deck: list: "ລາຍການ" channel: "ຊ່ອງ" mentions: "ກ່າວເຖິງ" +_webhookSettings: + name: "ຊື່" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 8d4a41bf34..a863103e2c 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "ยกเลิกทำเครื่องหมายเ enterFileName: "พิมพ์ชื่อไฟล์" mute: "ปิดเสียง" unmute: "ยกเลิกการปิดเสียง" +renoteMute: "ปิดเสียงรีโน้ต" +renoteUnmute: "เปิดเสียง รีโน้ต" block: "บล็อค" unblock: "เลิกปิดกั้น" suspend: "ถูกระงับ" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม" addAccount: "เพิ่มบัญชี" +reloadAccountsList: "รีโหลดรายการบัญชีใหม่" loginFailed: "การเข้าสู่ระบบไม่สำเร็จ" showOnRemote: "ดูบนอินสแตนซ์ระยะไกล" general: "ทั่วไป" @@ -503,6 +506,7 @@ objectStorageUseSSLDesc: "ปิดการทำงานนี้ไว้ objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี" objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API" objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในการอัปโหลด" +s3ForcePathStyleDesc: "ถ้าหากเปิดใช้งาน s3ForcePathStyle ชื่อบัคเก็ตนั้นอาจจะต้องรวมอยู่ในเส้นทางของ URL ซึ่งตรงข้ามกับชื่อโฮสต์ของ URL คุณอาจจะต้องเปิดใช้งานการตั้งค่านี้เมื่อใช้บริการต่างๆ เช่น อินสแตนซ์ Minio ที่โฮสต์เองนะ" serverLogs: "บันทึกของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" @@ -545,7 +549,9 @@ userSilenced: "ผู้ใช้รายนี้กำลังถูกป yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" tokenRevoked: "โทเค็นไม่ถูกต้อง" +tokenRevokedDescription: "โทเค็นนี้หมดอายุแล้วนะค่ะกรุณาเข้าสู่ระบบอีกครั้งนะ" accountDeleted: "ลบบัญชีแล้ว" +accountDeletedDescription: "บัญชีนี้ถูกลบไปแล้วนะ" menu: "เมนู" divider: "ตัวแบ่ง" addItem: "เพิ่มรายการ" @@ -914,6 +920,7 @@ pushNotificationNotSupported: "เบราว์เซอร์หรืออ sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว" sendPushNotificationReadMessageCaption: "การแจ้งเตือนที่มีข้อความ \"{emptyPushNotificationMessage}\" จะแสดงขึ้นมาในช่วงระยะเวลาสั้นๆ การดำเนินการนี้อาจทำให้เพิ่มการใช้งานแบตเตอรี่ของอุปกรณ์ถ้าหากมีนะ" windowMaximize: "ขยายใหญ่สุดแล้ว" +windowMinimize: "ย่อเล็กที่สุด" windowRestore: "เลิกทำ" caption: "รายละเอียด" loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้" @@ -955,11 +962,17 @@ copyErrorInfo: "คัดลอกรายละเอียดข้อผิ joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้" exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" +disableFederationConfirm: "ปิดใช้งานสหพันธ์จริงๆหรอแน่ใจแล้วนะ?" +disableFederationConfirmWarn: "แม้ว่าจะถูกยกเลิกเอาไว้โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป เว้นแต่ว่า...จะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องทำตรงนี้หรอกนะค่ะ" +disableFederationOk: "ปิดการใช้งาน" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" postToTheChannel: "โพสต์ลงช่อง" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" +reactionAcceptance: "การยอมรับรีแอคชั่น" likeOnly: "ที่ชอบเท่านั้น" +likeOnlyForRemote: "ไลค์สำหรับอินสแตนซ์ระยะไกลเท่านั้น" +rolesAssignedToMe: "บทบาทที่ได้รับมอบหมายให้ฉัน" resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" sensitiveWords: "คำที่ละเอียดอ่อน" sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" @@ -971,6 +984,22 @@ drivecleaner: "ทำความสะอาดไดรฟ์" retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง" retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" +enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" +enableChartsForFederatedInstances: "สร้างแผนภูมิข้อมูลอินสแตนซ์ระยะไกล" +showClipButtonInNoteFooter: "เพิ่ม \"คลิป\" เพื่อบันทึกเมนูการทำงาน" +largeNoteReactions: "ขยายรีแอคชั่นการแสดงผล" +noteIdOrUrl: "โน้ต ID หรือ URL" +accountMigration: "การโยกย้ายบัญชี" +accountMoved: "ผู้ใช้รายนี้ได้ย้ายไปยังบัญชีใหม่แล้ว:" +forceShowAds: "แสดงโฆษณาเสมอ" +_accountMigration: + moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" + moveToLabel: "บัญชีที่จะย้ายไปที่:" + moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com" + moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" + moveFromLabel: "บัญชีที่จะย้ายจาก:" + moveFromDescription: "สร้างนามแฝงสำหรับบัญชีที่จะย้ายจากบัญชีนี้ ถ้าหากคุณต้องการโอนผู้ติดตาม สิ่งนี้ต้องทำก่อนโอนก่อนนะค่ะ! หลังจากนั้น ป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" + migrationConfirm: "ย้ายข้อมูลบัญชีนี้ไปที่ {account} จริงๆนะ เมื่อมีการเริ่มต้นแล้ว กระบวนการนี้จะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ เพื่อให้แน่ใจยืนยันว่าคุณได้สร้างนามแฝงในบัญชีที่จะย้ายข้อมูลนะค่ะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: @@ -1267,6 +1296,8 @@ _role: followersMoreThanOrEq: "จำนวนผู้ติดตามมากกว่าหรือเท่ากับ\n" followingLessThanOrEq: "จำนวนบัญชีต่อไปนี้คือ น้อยกว่าหรือเท่ากับ" followingMoreThanOrEq: "จำนวนบัญชีต่อไปนี้คือ มากกว่าหรือเท่ากับ" + notesLessThanOrEq: "จำนวนโพสต์น้อยกว่าเท่ากับ" + notesMoreThanOrEq: "จำนวนโพสต์มากกว่าเท่ากับ" and: "และ" or: "หรือ" not: "ไม่" @@ -1866,5 +1897,16 @@ _drivecleaner: orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย" orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" _webhookSettings: + createWebhook: "สร้าง Webhook" name: "ชื่อ" + secret: "ความลับ" + events: "อีเว้นท์ Webhook" active: "เปิดใช้งาน" + _events: + follow: "เมื่อกำลังติดตามผู้ใช้" + followed: "เมื่อกำลังติดตามแล้ว" + note: "เมื่อกำลังโพสต์โน้ต" + reply: "เมื่อได้รับการตอบกลับ" + renote: "รีโน้ตแล้วเมื่อ" + reaction: "เมื่อได้รับรีแอคชั่น" + mention: "เมื่อกำลังถูกกล่าวถึง" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index d9df760186..62f6bc6c32 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -20,6 +20,7 @@ noNotes: "没有帖文" noNotifications: "无通知" instance: "服务器" settings: "设置" +notificationSettings: "通知设置" basicSettings: "基本设置" otherSettings: "其他设置" openInWindow: "在新窗口中打开" @@ -991,6 +992,7 @@ largeNoteReactions: "使用大图标来显示回应" noteIdOrUrl: "帖子ID或URL" accountMigration: "账户迁移" accountMoved: "此用户已迁移账户" +forceShowAds: "总是显示广告" _accountMigration: moveTo: "把这个账户迁移到新的账户" moveToLabel: "迁移后的账户" @@ -1406,6 +1408,8 @@ _channel: following: "正在关注" usersCount: "有{n}人参与" notesCount: "有{n}个帖子" + nameAndDescription: "名称与描述" + nameOnly: "仅名称" _menuDisplay: sideFull: "横向" sideIcon: "横向(图标)" @@ -1886,6 +1890,7 @@ _deck: channel: "频道" mentions: "提及" direct: "指定用户" + roleTimeline: "角色时间线" _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 48934463d0..09253db2d7 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -20,6 +20,7 @@ noNotes: "無貼文。" noNotifications: "沒有通知" instance: "實例" settings: "設定" +notificationSettings: "通知選項" basicSettings: "基本設定" otherSettings: "其他設定" openInWindow: "在新視窗開啟" @@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "如果不使用https進行API連接,請關閉" objectStorageUseProxy: "使用網路代理" objectStorageUseProxyDesc: "如果不使用代理進行API連接,請關閉" objectStorageSetPublicRead: "上傳時設定為\"public-read\"" +s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" @@ -560,7 +562,7 @@ inboxUrl: "收件夾URL" addedRelays: "已加入的中繼" serviceworkerInfo: "您需要啟用推送通知" deletedNote: "已删除的貼文" -invisibleNote: "隱藏的貼文" +invisibleNote: "私密的貼文" enableInfiniteScroll: "啟用自動滾動頁面模式" visibility: "可見性" poll: "投票" @@ -919,6 +921,7 @@ pushNotificationNotSupported: "瀏覽器或實例不支援推播通知" sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "復原" caption: "標題" loggedInAsBot: "以機器人帳戶登入中" @@ -960,6 +963,9 @@ copyErrorInfo: "複製錯誤資訊" joinThisServer: "在此伺服器上註冊" exploreOtherServers: "探索其他伺服器" letsLookAtTimeline: "看看時間軸" +disableFederationConfirm: "要停止聯邦功能嗎?" +disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變成私密的。在大部分的情況下,沒有必要停止聯邦功能。" +disableFederationOk: "停止聯邦功能" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" emailNotSupported: "這個伺服器不支援寄送郵件" postToTheChannel: "發布到頻道" @@ -985,9 +991,16 @@ showClipButtonInNoteFooter: "將摘錄添加至貼文" largeNoteReactions: "將貼文的反應放大顯示" noteIdOrUrl: "貼文ID或URL" accountMigration: "遷移帳戶" +accountMoved: "這個使用者已遷移至新的帳戶:" +forceShowAds: "總是顯示廣告" _accountMigration: moveTo: "將這個帳戶遷移至新的帳戶" - moveToLabel: "要遷移的帳戶:" + moveToLabel: "要遷移到的帳戶:" + moveAccountDescription: "這個操作不可撤銷。首先,請確認已在要遷移到的帳戶中為這個帳戶建立了一個別名。建立別名之後,像這樣輸入你要遷移到的帳戶:@person@instance.com" + moveFrom: "從其他帳戶遷移到這個帳戶" + moveFromLabel: "要遷移過來的帳戶:" + moveFromDescription: "如果你想把跟隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com" + migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" _achievements: earnedAt: "獲得日期" _types: @@ -1395,6 +1408,8 @@ _channel: following: "關注中" usersCount: "有{n}人參與" notesCount: "有{n}個貼文" + nameAndDescription: "名稱與說明" + nameOnly: "僅名稱" _menuDisplay: sideFull: "側向" sideIcon: "側向(圖示)" @@ -1875,6 +1890,7 @@ _deck: channel: "頻道" mentions: "提及" direct: "指定使用者" + roleTimeline: "角色時間軸" _dialog: charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" diff --git a/package.json b/package.json index 89733ea179..a22bb2873f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.11.1", + "version": "13.11.3", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/assets/tabler-badges/medal.png b/packages/backend/assets/tabler-badges/medal.png new file mode 100644 index 0000000000..44dc7b8c1e Binary files /dev/null and b/packages/backend/assets/tabler-badges/medal.png differ diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index cb713b25ad..174d0d8beb 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -37,8 +37,24 @@ const $redis: Provider = { inject: [DI.config], }; -const $redisForPubsub: Provider = { - provide: DI.redisForPubsub, +const $redisForPub: Provider = { + provide: DI.redisForPub, + useFactory: (config) => { + const redis = new Redis({ + port: config.redisForPubsub.port, + host: config.redisForPubsub.host, + family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, + password: config.redisForPubsub.pass, + keyPrefix: `${config.redisForPubsub.prefix}:`, + db: config.redisForPubsub.db ?? 0, + }); + return redis; + }, + inject: [DI.config], +}; + +const $redisForSub: Provider = { + provide: DI.redisForSub, useFactory: (config) => { const redis = new Redis({ port: config.redisForPubsub.port, @@ -57,14 +73,15 @@ const $redisForPubsub: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $redis, $redisForPubsub], - exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule], + providers: [$config, $db, $redis, $redisForPub, $redisForSub], + exports: [$config, $db, $redis, $redisForPub, $redisForSub, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @Inject(DI.db) private db: DataSource, @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis, + @Inject(DI.redisForPub) private redisForPub: Redis.Redis, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, ) {} async onApplicationShutdown(signal: string): Promise { @@ -79,7 +96,8 @@ export class GlobalModule implements OnApplicationShutdown { await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), - this.redisForPubsub.disconnect(), + this.redisForPub.disconnect(), + this.redisForSub.disconnect(), ]); } } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 35266ac16d..166c78f479 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -27,8 +27,8 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisForPubsub) - private redisForPubsub: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown { this.antennasFetched = false; this.antennas = []; - this.redisForPubsub.on('message', this.onRedisMessage); + this.redisForSub.on('message', this.onRedisMessage); } @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisForPubsub.off('message', this.onRedisMessage); + this.redisForSub.off('message', this.onRedisMessage); } @bindThis @@ -91,14 +91,24 @@ export class AntennaService implements OnApplicationShutdown { } @bindThis - public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { - this.redisClient.xadd( - `antennaTimeline:${antenna.id}`, - 'MAXLEN', '~', '200', - `${this.idService.parse(note.id).date.getTime()}-*`, - 'note', note.id); - - this.globalEventService.publishAntennaStream(antenna.id, 'note', note); + public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise { + const antennas = await this.getAntennas(); + const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); + const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); + + const redisPipeline = this.redisClient.pipeline(); + + for (const antenna of matchedAntennas) { + redisPipeline.xadd( + `antennaTimeline:${antenna.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + this.globalEventService.publishAntennaStream(antenna.id, 'note', note); + } + + redisPipeline.exec(); } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index d74f3e8788..561face5c3 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -27,8 +27,8 @@ export class CacheService implements OnApplicationShutdown { @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisForPubsub) - private redisForPubsub: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -116,7 +116,7 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.redisForPubsub.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -167,6 +167,6 @@ export class CacheService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisForPubsub.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index dc365986fe..185171dee2 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -13,6 +13,7 @@ import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { Config } from '@/config.js'; import { query } from '@/misc/prelude/url.js'; +import type { Serialized } from '@/server/api/stream/types.js'; @Injectable() export class CustomEmojiService { @@ -43,12 +44,14 @@ export class CustomEmojiService { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60 * 3, // 3m fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), - toRedisConverter: (value) => JSON.stringify(value.values()), + toRedisConverter: (value) => JSON.stringify(Array.from(value.values())), fromRedisConverter: (value) => { - // 原因不明だが配列以外が入ってくることがあるため - if (!Array.isArray(JSON.parse(value))) return undefined; - return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])); - }, // TODO: Date型の変換 + if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) + return new Map(JSON.parse(value).map((x: Serialized) => [x.name, { + ...x, + updatedAt: x.updatedAt ? new Date(x.updatedAt) : null, + }])); + }, }); } @@ -271,16 +274,7 @@ export class CustomEmojiService { const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); if (emoji == null) return null; - - const isLocal = emoji.host == null; - const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - const url = isLocal - ? emojiUrl - : this.config.proxyRemoteFiles - ? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}` - : emojiUrl; - - return url; + return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) } /** diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 25c064a2b4..2c2687a90c 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -14,11 +14,13 @@ import type { MainStreamTypes, NoteStreamTypes, UserListStreamTypes, + RoleTimelineStreamTypes, } from '@/server/api/stream/types.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { Role } from '@/models'; @Injectable() export class GlobalEventService { @@ -26,8 +28,8 @@ export class GlobalEventService { @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForPub) + private redisForPub: Redis.Redis, ) { } @@ -37,7 +39,7 @@ export class GlobalEventService { { type: type, body: null } : { type: type, body: value }; - this.redisClient.publish(this.config.host, JSON.stringify({ + this.redisForPub.publish(this.config.host, JSON.stringify({ channel: channel, message: message, })); @@ -81,6 +83,11 @@ export class GlobalEventService { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishRoleTimelineStream(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishNotesStream(note: Packed<'Note'>): void { this.publish('notesStream', null, note); diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 2b6160c82e..1322927c2c 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown { private intervalId: NodeJS.Timer; constructor( - @Inject(DI.redisForPubsub) - private redisForPubsub: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.db) private db: DataSource, @@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown { }, 1000 * 60 * 5); } - this.redisForPubsub.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { clearInterval(this.intervalId); - this.redisForPubsub.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 5c4d13f178..79629cb2a8 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -329,7 +329,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.redisClient.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', '1000', - `${this.idService.parse(note.id).date.getTime()}-*`, + '*', 'note', note.id); } @@ -493,14 +493,7 @@ export class NoteCreateService implements OnApplicationShutdown { } }); - // Antenna - for (const antenna of (await this.antennaService.getAntennas())) { - this.antennaService.checkHitAntenna(antenna, note, user).then(hit => { - if (hit) { - this.antennaService.addNoteToAntenna(antenna, note, user); - } - }); - } + this.antennaService.addNoteToAntennas(note, user); if (data.reply) { this.saveReply(data.reply, note); @@ -554,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown { this.globalEventService.publishNotesStream(noteObj); + this.roleService.addNoteToRoleTimeline(noteObj); + this.webhookService.getActiveWebhooks().then(webhooks => { webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); for (const webhook of webhooks) { diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index c44dddea41..6691c42836 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -66,6 +66,7 @@ export class NotificationService implements OnApplicationShutdown { @bindThis private postReadAllNotifications(userId: User['id']) { this.globalEventService.publishMainStream(userId, 'readAllNotifications'); + this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); } @bindThis @@ -99,7 +100,7 @@ export class NotificationService implements OnApplicationShutdown { const redisIdPromise = this.redisClient.xadd( `notificationTimeline:${notifieeId}`, 'MAXLEN', '~', '300', - `${this.idService.parse(notification.id).date.getTime()}-*`, + '*', 'data', JSON.stringify(notification)); const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 69020f7e84..9b44cf6413 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -1,12 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; +import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema'; import { getNoteSummary } from '@/misc/get-note-summary.js'; -import type { SwSubscriptionsRepository } from '@/models/index.js'; +import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; +import { RedisKVCache } from '@/misc/cache.js'; // Defined also packages/sw/types.ts#L13 type PushNotificationsTypes = { @@ -15,6 +17,7 @@ type PushNotificationsTypes = { antenna: { id: string, name: string }; note: Packed<'Note'>; }; + 'readAllNotifications': undefined; }; // Reduce length because push message servers have character limits @@ -40,15 +43,27 @@ function truncateBody(type: T, body: Pus @Injectable() export class PushNotificationService { + private subscriptionsCache: RedisKVCache; + constructor( @Inject(DI.config) private config: Config, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, private metaService: MetaService, ) { + this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', { + lifetime: 1000 * 60 * 60 * 1, // 1h + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), + }); } @bindThis @@ -62,12 +77,13 @@ export class PushNotificationService { meta.swPublicKey, meta.swPrivateKey); - // Fetch - const subscriptions = await this.swSubscriptionsRepository.findBy({ - userId: userId, - }); + const subscriptions = await this.subscriptionsCache.fetch(userId); for (const subscription of subscriptions) { + if ([ + 'readAllNotifications', + ].includes(type) && !subscription.sendReadMessage) continue; + const pushSubscription = { endpoint: subscription.endpoint, keys: { diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 8733a7d7eb..bac85d7a15 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -3,7 +3,7 @@ import Bull from 'bull'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js'; +import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js'; function q(config: Config, name: string, limitPerSec = -1) { return new Bull(name, { @@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; -export type DbQueue = Bull.Queue; +export type DbQueue = Bull.Queue>; +export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; export type WebhookDeliverQueue = Bull.Queue; @@ -75,6 +76,12 @@ const $db: Provider = { inject: [DI.config], }; +const $relationship: Provider = { + provide: 'queue:relationship', + useFactory: (config: Config) => q(config, 'relationship'), + inject: [DI.config], +}; + const $objectStorage: Provider = { provide: 'queue:objectStorage', useFactory: (config: Config) => q(config, 'objectStorage'), @@ -96,6 +103,7 @@ const $webhookDeliver: Provider = { $deliver, $inbox, $db, + $relationship, $objectStorage, $webhookDeliver, ], @@ -105,6 +113,7 @@ const $webhookDeliver: Provider = { $deliver, $inbox, $db, + $relationship, $objectStorage, $webhookDeliver, ], diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 498ceced7a..375ac49911 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -6,9 +6,10 @@ import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { ThinUser } from '../queue/types.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; +import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; +import Bull from 'bull'; @Injectable() export class QueueService { @@ -21,6 +22,7 @@ export class QueueService { @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) {} @@ -56,7 +58,7 @@ export class QueueService { activity: activity, signature, }; - + return this.inboxQueue.add(data, { attempts: this.config.inboxJobMaxAttempts ?? 8, timeout: 5 * 60 * 1000, // 5min @@ -71,7 +73,7 @@ export class QueueService { @bindThis public createDeleteDriveFilesJob(user: ThinUser) { return this.dbQueue.add('deleteDriveFiles', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -81,7 +83,7 @@ export class QueueService { @bindThis public createExportCustomEmojisJob(user: ThinUser) { return this.dbQueue.add('exportCustomEmojis', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -91,7 +93,7 @@ export class QueueService { @bindThis public createExportNotesJob(user: ThinUser) { return this.dbQueue.add('exportNotes', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -101,7 +103,7 @@ export class QueueService { @bindThis public createExportFavoritesJob(user: ThinUser) { return this.dbQueue.add('exportFavorites', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -111,7 +113,7 @@ export class QueueService { @bindThis public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { return this.dbQueue.add('exportFollowing', { - user: user, + user: { id: user.id }, excludeMuting, excludeInactive, }, { @@ -123,7 +125,7 @@ export class QueueService { @bindThis public createExportMuteJob(user: ThinUser) { return this.dbQueue.add('exportMuting', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -133,7 +135,7 @@ export class QueueService { @bindThis public createExportBlockingJob(user: ThinUser) { return this.dbQueue.add('exportBlocking', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -143,7 +145,7 @@ export class QueueService { @bindThis public createExportUserListsJob(user: ThinUser) { return this.dbQueue.add('exportUserLists', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -153,7 +155,7 @@ export class QueueService { @bindThis public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importFollowing', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -161,10 +163,16 @@ export class QueueService { }); } + @bindThis + public createImportFollowingToDbJob(user: ThinUser, targets: string[]) { + const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel })); + return this.dbQueue.addBulk(jobs); + } + @bindThis public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importMuting', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -175,7 +183,7 @@ export class QueueService { @bindThis public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importBlocking', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -183,10 +191,32 @@ export class QueueService { }); } + @bindThis + public createImportBlockingToDbJob(user: ThinUser, targets: string[]) { + const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel })); + return this.dbQueue.addBulk(jobs); + } + + @bindThis + private generateToDbJobData>(name: T, data: D): { + name: string, + data: D, + opts: Bull.JobOptions, + } { + return { + name, + data, + opts: { + removeOnComplete: true, + removeOnFail: true, + }, + }; + } + @bindThis public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importUserLists', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -197,7 +227,7 @@ export class QueueService { @bindThis public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importCustomEmojis', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -208,7 +238,7 @@ export class QueueService { @bindThis public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { return this.dbQueue.add('deleteAccount', { - user: user, + user: { id: user.id }, soft: opts.soft, }, { removeOnComplete: true, @@ -216,6 +246,51 @@ export class QueueService { }); } + @bindThis + public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean }[]) { + const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { + const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { + const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): { + name: string, + data: RelationshipJobData, + opts: Bull.JobOptions, + } { + return { + name, + data: { + from: { id: data.from.id }, + to: { id: data.to.id }, + silent: data.silent, + requestId: data.requestId, + }, + opts: { + removeOnComplete: true, + removeOnFail: true, + }, + }; + } + @bindThis public createDeleteObjectStorageFileJob(key: string) { return this.objectStorageQueue.add('deleteFile', { @@ -246,7 +321,7 @@ export class QueueService { createdAt: Date.now(), eventId: uuid(), }; - + return this.webhookDeliverQueue.add(data, { attempts: 4, timeout: 1 * 60 * 1000, // 1min @@ -264,7 +339,7 @@ export class QueueService { //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); this.deliverQueue.clean(0, 'delayed'); - + this.inboxQueue.once('cleaned', (jobs, status) => { //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index c8ebe1adb7..2a4271aa98 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Packed } from '@/misc/json-schema'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -64,8 +65,11 @@ export class RoleService implements OnApplicationShutdown { public static NotAssignedError = class extends Error {}; constructor( - @Inject(DI.redisForPubsub) - private redisForPubsub: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -87,7 +91,7 @@ export class RoleService implements OnApplicationShutdown { this.rolesCache = new MemorySingleCache(1000 * 60 * 60 * 1); this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); - this.redisForPubsub.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -398,8 +402,27 @@ export class RoleService implements OnApplicationShutdown { this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); } + @bindThis + public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise { + const roles = await this.getUserRoles(note.userId); + + const redisPipeline = this.redisClient.pipeline(); + + for (const role of roles) { + redisPipeline.xadd( + `roleTimeline:${role.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); + } + + redisPipeline.exec(); + } + @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisForPubsub.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index b3e306346e..3ca22f8bbc 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -24,7 +24,7 @@ export class UserBlockingService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - + @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, @@ -54,12 +54,12 @@ export class UserBlockingService implements OnModuleInit { } @bindThis - public async block(blocker: User, blockee: User) { + public async block(blocker: User, blockee: User, silent = false) { await Promise.all([ - this.cancelRequest(blocker, blockee), - this.cancelRequest(blockee, blocker), - this.userFollowingService.unfollow(blocker, blockee), - this.userFollowingService.unfollow(blockee, blocker), + this.cancelRequest(blocker, blockee, silent), + this.cancelRequest(blockee, blocker, silent), + this.userFollowingService.unfollow(blocker, blockee, silent), + this.userFollowingService.unfollow(blockee, blocker, silent), this.removeFromList(blockee, blocker), ]); @@ -89,7 +89,7 @@ export class UserBlockingService implements OnModuleInit { } @bindThis - private async cancelRequest(follower: User, followee: User) { + private async cancelRequest(follower: User, followee: User, silent = false) { const request = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -110,7 +110,7 @@ export class UserBlockingService implements OnModuleInit { }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); } - if (this.userEntityService.isLocalUser(follower)) { + if (this.userEntityService.isLocalUser(follower) && !silent) { this.userEntityService.pack(followee, follower, { detail: true, }).then(async packed => { diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index d7bb8f3920..a8eded6733 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -20,6 +20,7 @@ import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; +import type { Config } from '@/config.js'; import Logger from '../logger.js'; const logger = new Logger('following/create'); @@ -43,7 +44,10 @@ export class UserFollowingService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - + + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -79,7 +83,7 @@ export class UserFollowingService implements OnModuleInit { } @bindThis - public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise { + public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }), @@ -139,7 +143,7 @@ export class UserFollowingService implements OnModuleInit { } } - await this.insertFollowingDoc(followee, follower); + await this.insertFollowingDoc(followee, follower, silent); if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); @@ -155,6 +159,7 @@ export class UserFollowingService implements OnModuleInit { follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, + silent = false, ): Promise { if (follower.id === followee.id) return; @@ -233,7 +238,7 @@ export class UserFollowingService implements OnModuleInit { this.perUserFollowingChart.update(follower, followee, true); // Publish follow event - if (this.userEntityService.isLocalUser(follower)) { + if (this.userEntityService.isLocalUser(follower) && !silent) { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { @@ -410,7 +415,7 @@ export class UserFollowingService implements OnModuleInit { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); this.queueService.deliver(follower, content, followee.inbox, false); } } diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bc726a1feb..08cc907ebf 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class UserListService { @@ -29,6 +30,7 @@ export class UserListService { private roleService: RoleService, private globalEventService: GlobalEventService, private proxyAccountService: ProxyAccountService, + private queueService: QueueService, ) { } @@ -47,14 +49,14 @@ export class UserListService { userId: target.id, userListId: list.id, } as UserListJoining); - + this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); - + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする if (this.userEntityService.isRemoteUser(target)) { const proxy = await this.proxyAccountService.fetch(); if (proxy) { - this.userFollowingService.follow(proxy, target); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); } } } diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 85594f8557..926115613b 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown { private webhooks: Webhook[] = []; constructor( - @Inject(DI.redisForPubsub) - private redisForPubsub: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, ) { //this.onMessage = this.onMessage.bind(this); - this.redisForPubsub.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisForPubsub.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 72e9b25544..987002606f 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -84,7 +84,7 @@ export class ChannelEntityService { } : {}), ...(detailed ? { - pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), + pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)), } : {}), }; } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 482e8f83e1..d4b1fb31b1 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -2,7 +2,8 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), redis: Symbol('redis'), - redisForPubsub: Symbol('redisForPubsub'), + redisForPub: Symbol('redisForPub'), + redisForSub: Symbol('redisForSub'), //#region Repositories usersRepository: Symbol('usersRepository'), diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index a4abd4f878..f413246a1f 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -8,7 +8,7 @@ export class RedisKVCache { private memoryCache: MemoryKVCache; private fetcher: (key: string) => Promise; private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache + private fromRedisConverter: (value: string) => T | undefined; constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { lifetime: RedisKVCache['lifetime']; @@ -92,7 +92,7 @@ export class RedisSingleCache { private memoryCache: MemorySingleCache; private fetcher: () => Promise; private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache + private fromRedisConverter: (value: string) => T | undefined; constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { lifetime: RedisSingleCache['lifetime']; diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts index 910e2bea45..233a36dd04 100644 --- a/packages/backend/src/queue/DbQueueProcessorsService.ts +++ b/packages/backend/src/queue/DbQueueProcessorsService.ts @@ -52,8 +52,10 @@ export class DbQueueProcessorsService { q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); + q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); + q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 6a8f35cdda..4db9b38547 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; +import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -32,6 +33,7 @@ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessor import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; @Module({ imports: [ @@ -61,9 +63,11 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro DeleteAccountProcessorService, DeleteFileProcessorService, CleanRemoteFilesProcessorService, + RelationshipProcessorService, SystemQueueProcessorsService, ObjectStorageQueueProcessorsService, DbQueueProcessorsService, + RelationshipQueueProcessorsService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 8457747cb0..706110f6fc 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; +import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; @Injectable() export class QueueProcessorService { @@ -27,6 +28,7 @@ export class QueueProcessorService { private systemQueueProcessorsService: SystemQueueProcessorsService, private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, private dbQueueProcessorsService: DbQueueProcessorsService, + private relationshipQueueProcessorsService: RelationshipQueueProcessorsService, private webhookDeliverProcessorService: WebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, @@ -52,14 +54,15 @@ export class QueueProcessorService { }; } } - + const systemLogger = this.logger.createSubLogger('system'); const deliverLogger = this.logger.createSubLogger('deliver'); const webhookLogger = this.logger.createSubLogger('webhook'); const inboxLogger = this.logger.createSubLogger('inbox'); const dbLogger = this.logger.createSubLogger('db'); + const relationshipLogger = this.logger.createSubLogger('relationship'); const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - + this.queueService.systemQueue .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) @@ -67,7 +70,7 @@ export class QueueProcessorService { .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); - + this.queueService.deliverQueue .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) @@ -75,7 +78,7 @@ export class QueueProcessorService { .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - + this.queueService.inboxQueue .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) @@ -83,7 +86,7 @@ export class QueueProcessorService { .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); - + this.queueService.dbQueue .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) @@ -91,7 +94,15 @@ export class QueueProcessorService { .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); - + + this.queueService.relationshipQueue + .on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`)); + this.queueService.objectStorageQueue .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) @@ -99,7 +110,7 @@ export class QueueProcessorService { .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); - + this.queueService.webhookDeliverQueue .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) @@ -107,26 +118,27 @@ export class QueueProcessorService { .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - + this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); this.dbQueueProcessorsService.start(this.queueService.dbQueue); + this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue); this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); - + this.queueService.systemQueue.add('tickCharts', { }, { repeat: { cron: '55 * * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('resyncCharts', { }, { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('cleanCharts', { }, { repeat: { cron: '0 0 * * *' }, @@ -138,19 +150,19 @@ export class QueueProcessorService { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('clean', { }, { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('checkExpiredMutings', { }, { repeat: { cron: '*/5 * * * *' }, removeOnComplete: true, }); - + this.systemQueueProcessorsService.start(this.queueService.systemQueue); } } diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts new file mode 100644 index 0000000000..af086fa4e7 --- /dev/null +++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; +import type Bull from 'bull'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; + +@Injectable() +export class RelationshipQueueProcessorsService { + constructor( + @Inject(DI.config) + private config: Config, + + private relationshipProcessorService: RelationshipProcessorService, + ) { + } + + @bindThis + public start(q: Bull.Queue): void { + const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative? + q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); + q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); + q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); + q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job)); + } +} diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index fa0c1733f6..604497cf54 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -7,7 +7,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index 7f2c2d08b5..a020006732 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -36,7 +36,7 @@ export class ExportBlockingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index e9330772b9..daefcdf2f5 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; @Injectable() export class ExportFavoritesProcessorService { @@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 064b126e44..59443de57f 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbExportFollowingData } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -40,7 +40,7 @@ export class ExportFollowingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting following of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 94c7ea8a46..a2a718b892 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -39,7 +39,7 @@ export class ExportMutingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting muting of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 2f74dd63cc..1aa20d6f1d 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; @Injectable() export class ExportNotesProcessorService { @@ -39,7 +39,7 @@ export class ExportNotesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting notes of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index 6400161b8c..ce8ed2f5e8 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -39,7 +39,7 @@ export class ExportUserListsProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts index b8d9b3a52d..3f075b02d2 100644 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -1,38 +1,31 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; +import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserImportJobData } from '../types.js'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class ImportBlockingProcessorService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private queueService: QueueService, private utilityService: UtilityService, - private userBlockingService: UserBlockingService, private remoteUserResolveService: RemoteUserResolveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, @@ -59,46 +52,50 @@ export class ImportBlockingProcessorService { } const csv = await this.downloadService.downloadTextFile(file.url); + const targets = csv.trim().split('\n'); + this.queueService.createImportBlockingToDbJob({ id: user.id }, targets); - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Block[${linenum}] ${target.id} ...`); - - await this.userBlockingService.block(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); + this.logger.succ('Import jobs created'); done(); } + + @bindThis + public async processDb(job: Bull.Job): Promise { + const line = job.data.target; + const user = job.data.user; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + if (!host) return; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) return; + + if (target == null) { + target = await this.remoteUserResolveService.resolveUser(username, host); + } + + if (target == null) { + throw `Unable to resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) return; + + this.logger.info(`Block ${target.id} ...`); + + this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]); + } catch (e) { + this.logger.warn(`Error: ${e}`); + } + } } diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts index 037a6f2456..aa5cf12c50 100644 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -2,34 +2,30 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserImportJobData } from '../types.js'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class ImportFollowingProcessorService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private queueService: QueueService, private utilityService: UtilityService, - private userFollowingService: UserFollowingService, private remoteUserResolveService: RemoteUserResolveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, @@ -56,46 +52,50 @@ export class ImportFollowingProcessorService { } const csv = await this.downloadService.downloadTextFile(file.url); + const targets = csv.trim().split('\n'); + this.queueService.createImportFollowingToDbJob({ id: user.id }, targets); - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Follow[${linenum}] ${target.id} ...`); - - this.userFollowingService.follow(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); + this.logger.succ('Import jobs created'); done(); } + + @bindThis + public async processDb(job: Bull.Job): Promise { + const line = job.data.target; + const user = job.data.user; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + if (!host) return; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) return; + + if (target == null) { + target = await this.remoteUserResolveService.resolveUser(username, host); + } + + if (target == null) { + throw `Unable to resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) return; + + this.logger.info(`Follow ${target.id} ...`); + + this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true }]); + } catch (e) { + this.logger.warn(`Error: ${e}`); + } + } } diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts index 83d382057b..379994ee79 100644 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -66,11 +66,13 @@ export class ImportMutingProcessorService { const acct = line.split(',')[0].trim(); const { username, host } = Acct.parse(acct); - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + if (!host) continue; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ host: IsNull(), usernameLower: username.toLowerCase(), }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), + host: this.utilityService.toPuny(host), usernameLower: username.toLowerCase(), }); diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts new file mode 100644 index 0000000000..a5006dcf03 --- /dev/null +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Bull from 'bull'; + +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { bindThis } from '@/decorators.js'; +import type Logger from '@/logger.js'; + +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { RelationshipJobData } from '../types.js'; +import type { UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class RelationshipProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private queueLoggerService: QueueLoggerService, + private userFollowingService: UserFollowingService, + private userBlockingService: UserBlockingService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('follow-block'); + } + + @bindThis + public async processFollow(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id}`); + await this.userFollowingService.follow(job.data.from, job.data.to, job.data.requestId, job.data.silent); + return 'ok'; + } + + @bindThis + public async processUnfollow(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`); + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userFollowingService.unfollow(follower, followee, job.data.silent); + return 'ok'; + } + + @bindThis + public async processBlock(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`); + const [blockee, blocker] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userBlockingService.block(blockee, blocker, job.data.silent); + return 'ok'; + } + + @bindThis + public async processUnblock(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`); + const [blockee, blocker] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userBlockingService.unblock(blockee, blocker); + return 'ok'; + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 5d650c6864..23c973d449 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -21,9 +21,39 @@ export type InboxJobData = { signature: httpSignature.IParsedSignature; }; -export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData; +export type RelationshipJobData = { + from: ThinUser; + to: ThinUser; + silent?: boolean; + requestId?: string; +} -export type DbUserJobData = { +export type DbJobData = DbJobMap[T]; + +export type DbJobMap = { + deleteDriveFiles: DbJobDataWithUser; + exportCustomEmojis: DbJobDataWithUser; + exportNotes: DbJobDataWithUser; + exportFavorites: DbJobDataWithUser; + exportFollowing: DbExportFollowingData; + exportMuting: DbJobDataWithUser; + exportBlocking: DbJobDataWithUser; + exportUserLists: DbJobDataWithUser; + importFollowing: DbUserImportJobData; + importFollowingToDb: DbUserImportToDbJobData; + importMuting: DbUserImportJobData; + importBlocking: DbUserImportJobData; + importBlockingToDb: DbUserImportToDbJobData; + importUserLists: DbUserImportJobData; + importCustomEmojis: DbUserImportJobData; + deleteAccount: DbUserDeleteJobData; +} + +export type DbJobDataWithUser = { + user: ThinUser; +} + +export type DbExportFollowingData = { user: ThinUser; excludeMuting: boolean; excludeInactive: boolean; @@ -39,6 +69,11 @@ export type DbUserImportJobData = { fileId: DriveFile['id']; }; +export type DbUserImportToDbJobData = { + user: ThinUser; + target: string; +}; + export type ObjectStorageJobData = ObjectStorageFileJobData | Record; export type ObjectStorageFileJobData = { diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 5799622074..e13e9265ab 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -6,7 +6,7 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import accepts from 'accepts'; import vary from 'vary'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -54,6 +54,9 @@ export class ActivityPubServerService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + private utilityService: UtilityService, private userEntityService: UserEntityService, private apRendererService: ApRendererService, @@ -205,22 +208,22 @@ export class ActivityPubServerService { reply.code(400); return; } - + const page = request.query.page === 'true'; - + const user = await this.usersRepository.findOneBy({ id: userId, host: IsNull(), }); - + if (user == null) { reply.code(404); return; } - + //#region Check ff visibility const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - + if (profile.ffVisibility === 'private') { reply.code(403); reply.header('Cache-Control', 'public, max-age=30'); @@ -231,31 +234,31 @@ export class ActivityPubServerService { return; } //#endregion - + const limit = 10; const partOf = `${this.config.url}/users/${userId}/following`; - + if (page) { const query = { followerId: user.id, } as FindOptionsWhere; - + // カーソルが指定されている場合 if (cursor) { query.id = LessThan(cursor); } - + // Get followings const followings = await this.followingsRepository.find({ where: query, take: limit + 1, order: { id: -1 }, }); - + // 「次のページ」があるかどうか const inStock = followings.length === limit + 1; if (inStock) followings.pop(); - + const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ @@ -269,7 +272,7 @@ export class ActivityPubServerService { cursor: followings[followings.length - 1].id, })}` : undefined, ); - + this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } else { @@ -330,33 +333,33 @@ export class ActivityPubServerService { reply.code(400); return; } - + const untilId = request.query.until_id; if (untilId != null && typeof untilId !== 'string') { reply.code(400); return; } - + const page = request.query.page === 'true'; - + if (countIf(x => x != null, [sinceId, untilId]) > 1) { reply.code(400); return; } - + const user = await this.usersRepository.findOneBy({ id: userId, host: IsNull(), }); - + if (user == null) { reply.code(404); return; } - + const limit = 20; const partOf = `${this.config.url}/users/${userId}/outbox`; - + if (page) { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) .andWhere('note.userId = :userId', { userId: user.id }) @@ -365,11 +368,11 @@ export class ActivityPubServerService { .orWhere('note.visibility = \'home\''); })) .andWhere('note.localOnly = FALSE'); - + const notes = await query.take(limit).getMany(); - + if (sinceId) notes.reverse(); - + const activities = await Promise.all(notes.map(note => this.packActivity(note))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ @@ -387,7 +390,7 @@ export class ActivityPubServerService { until_id: notes[notes.length - 1].id, })}` : undefined, ); - + this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } else { @@ -457,7 +460,7 @@ export class ActivityPubServerService { // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { vary(reply.raw, 'Accept'); - + const note = await this.notesRepository.findOneBy({ id: request.params.note, visibility: In(['public', 'home']), @@ -639,6 +642,41 @@ export class ActivityPubServerService { return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); }); + // follow + fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { + // This may be used before the follow is completed, so we do not + // check if the following exists and only check if the follow request exists. + + const followRequest = await this.followRequestsRepository.findOneBy({ + id: request.params.followRequestId, + }); + + if (followRequest == null) { + reply.code(404); + return; + } + + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: followRequest.followerId, + host: IsNull(), + }), + this.usersRepository.findOneBy({ + id: followRequest.followeeId, + host: Not(IsNull()), + }), + ]); + + if (follower == null || followee == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + }); + done(); } } diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 6bae0bafda..da86b2c1d3 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { ClientLoggerService } from './web/ClientLoggerService.js'; +import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; @Module({ imports: [ @@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; ], providers: [ ClientServerService, + ClientLoggerService, FeedService, UrlPreviewService, ActivityPubServerService, @@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; DriveChannelService, GlobalTimelineChannelService, HashtagChannelService, + RoleTimelineChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index b806ad5ca3..d3b1c7786d 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -89,7 +89,7 @@ export class ApiServerService { Params: { endpoint: string; }, Body: Record, Querystring: Record, - }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => { + }>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => { if (request.method === 'GET' && !endpoint.meta.allowGet) { reply.code(405); reply.send(); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5a53b3faf7..689f90287e 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -98,6 +98,7 @@ import * as ep___channels_update from './endpoints/channels/update.js'; import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; +import * as ep___channels_search from './endpoints/channels/search.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -293,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_users from './endpoints/roles/users.js'; +import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -431,6 +433,7 @@ const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; +const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; @@ -626,6 +629,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; +const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; @@ -768,6 +772,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_favorite, $channels_unfavorite, $channels_myFavorites, + $channels_search, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -963,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $roles_list, $roles_show, $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, @@ -1099,6 +1105,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_favorite, $channels_unfavorite, $channels_myFavorites, + $channels_search, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1294,6 +1301,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $roles_list, $roles_show, $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index e0e5b71a82..769a4490d6 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -22,8 +22,8 @@ export class StreamingApiServerService { @Inject(DI.config) private config: Config, - @Inject(DI.redisForPubsub) - private redisForPubsub: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -81,7 +81,7 @@ export class StreamingApiServerService { ev.emit(parsed.channel, parsed.message); } - this.redisForPubsub.on('message', onRedisMessage); + this.redisForSub.on('message', onRedisMessage); const main = new MainStreamConnection( this.channelsService, @@ -111,7 +111,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); main.dispose(); - this.redisForPubsub.off('message', onRedisMessage); + this.redisForSub.off('message', onRedisMessage); if (intervalId) clearInterval(intervalId); }); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index fd268c7912..d0fe6a57c1 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -98,6 +98,7 @@ import * as ep___channels_update from './endpoints/channels/update.js'; import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; +import * as ep___channels_search from './endpoints/channels/search.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -293,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_users from './endpoints/roles/users.js'; +import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -429,6 +431,7 @@ const eps = [ ['channels/favorite', ep___channels_favorite], ['channels/unfavorite', ep___channels_unfavorite], ['channels/my-favorites', ep___channels_myFavorites], + ['channels/search', ep___channels_search], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], @@ -624,6 +627,7 @@ const eps = [ ['roles/list', ep___roles_list], ['roles/show', ep___roles_show], ['roles/users', ep___roles_users], + ['roles/notes', ep___roles_notes], ['request-reset-password', ep___requestResetPassword], ['reset-db', ep___resetDb], ['reset-password', ep___resetPassword], diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index b073209a5b..54ce095488 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -29,7 +29,7 @@ export default class extends Endpoint { @Inject(DI.notesRepository) private followingsRepository: FollowingsRepository, - private userFollowingService: UserFollowingService, + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const followings = await this.followingsRepository.findBy({ @@ -41,9 +41,7 @@ export default class extends Endpoint { this.usersRepository.findOneByOrFail({ id: f.followeeId }), ]))); - for (const pair of pairs) { - this.userFollowingService.unfollow(pair[0], pair[1]); - } + this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true }))); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 3c99225272..eabbceac0e 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,15 +1,15 @@ +import { IsNull, Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -36,12 +36,10 @@ export default class extends Endpoint { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private userEntityService: UserEntityService, - private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, private roleService: RoleService, private moderationLogService: ModerationLogService, - private globalEventService: GlobalEventService, + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -71,20 +69,23 @@ export default class extends Endpoint { @bindThis private async unFollowAll(follower: User) { - const followings = await this.followingsRepository.findBy({ - followerId: follower.id, + const followings = await this.followingsRepository.find({ + where: { + followerId: follower.id, + followeeId: Not(IsNull()), + }, }); - + + const jobs: RelationshipJobData[] = []; for (const following of followings) { - const followee = await this.usersRepository.findOneBy({ - id: following.followeeId, - }); - - if (followee == null) { - throw `Cant find followee ${following.followeeId}`; + if (following.followeeId && following.followerId) { + jobs.push({ + from: { id: following.followerId }, + to: { id: following.followeeId }, + silent: true, + }); } - - await this.userFollowingService.unfollow(follower, followee, true); } + this.queueService.createUnfollowJob(jobs); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f08c20ae48..88623ce26a 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -76,17 +76,18 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const noteIdsRes = await this.redisClient.xrevrange( `antennaTimeline:${antenna.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit); if (noteIdsRes.length === 0) { return []; } - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); if (noteIds.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts new file mode 100644 index 0000000000..a954ba224c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import type { ChannelsRepository } from '@/models/index.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string' }, + type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, + }, + required: ['query'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId); + + if (ps.type === 'nameAndDescription') { + query.andWhere(new Brackets(qb => { qb + .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) + .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + })); + } else { + query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + } + + const channels = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index f27b4e86d4..ba0487f223 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -91,11 +91,12 @@ export default class extends Endpoint { 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 + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const notificationsRes = await this.redisClient.xrevrange( `notificationTimeline:${me.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + 'COUNT', limit); if (notificationsRes.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts new file mode 100644 index 0000000000..b45d4af1fe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { NotesRepository, RolesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'notes'], + + requireCredential: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e', + }, + }, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private idService: IdService, + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const noteIdsRes = await this.redisClient.xrevrange( + `roleTimeline:${role.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit); + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 6c340d8fb2..b001159ee8 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -41,8 +41,6 @@ export const paramDef = { ], } as const; -// TODO: avatar,bannerをJOINしたいけどエラーになる - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index f9ef8218c1..c77ba66028 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js'; import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; +import { RoleTimelineChannelService } from './channels/role-timeline.js'; @Injectable() export class ChannelsService { @@ -24,6 +25,7 @@ export class ChannelsService { private globalTimelineChannelService: GlobalTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, + private roleTimelineChannelService: RoleTimelineChannelService, private antennaChannelService: AntennaChannelService, private channelChannelService: ChannelChannelService, private driveChannelService: DriveChannelService, @@ -43,6 +45,7 @@ export class ChannelsService { case 'globalTimeline': return this.globalTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; + case 'roleTimeline': return this.roleTimelineChannelService; case 'antenna': return this.antennaChannelService; case 'channel': return this.channelChannelService; case 'drive': return this.driveChannelService; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 836c5aae6c..09b0005ac1 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -54,10 +54,10 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.user!.showTimelineReplies) { + if (note.reply && this.user && !this.user.showTimelineReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts new file mode 100644 index 0000000000..9d106c8b2f --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import Channel from '../channel.js'; +import { StreamMessages } from '../types.js'; + +class RoleTimelineChannel extends Channel { + public readonly chName = 'roleTimeline'; + public static shouldShare = false; + public static requireCredential = false; + private roleId: string; + + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any) { + this.roleId = params.roleId as string; + + this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: StreamMessages['roleTimeline']['payload']) { + if (data.type === 'note') { + const note = data.body; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + this.send('note', note); + } else { + this.send(data.type, data.body); + } + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); + } +} + +@Injectable() +export class RoleTimelineChannelService { + public readonly shouldShare = RoleTimelineChannel.shouldShare; + public readonly requireCredential = RoleTimelineChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): RoleTimelineChannel { + return new RoleTimelineChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index ed73897e73..d9dba682cd 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -148,6 +148,10 @@ export interface AntennaStreamTypes { note: Note; } +export interface RoleTimelineStreamTypes { + note: Packed<'Note'>; +} + export interface AdminStreamTypes { newAbuseUserReport: { id: AbuseUserReport['id']; @@ -168,7 +172,7 @@ type EventUnionFromDictionary< > = U[keyof U]; // redis通すとDateのインスタンスはstringに変換されるので -type Serialized = { +export type Serialized = { [K in keyof T]: T[K] extends Date ? string @@ -209,6 +213,10 @@ export type StreamMessages = { name: `userListStream:${UserList['id']}`; payload: EventUnionFromDictionary>; }; + roleTimeline: { + name: `roleTimelineStream:${Role['id']}`; + payload: EventUnionFromDictionary>; + }; antenna: { name: `antennaStream:${Antenna['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts new file mode 100644 index 0000000000..6a882aa766 --- /dev/null +++ b/packages/backend/src/server/web/ClientLoggerService.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class ClientLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('client'); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 99ae1b7af6..50b23a0682 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -1,6 +1,7 @@ import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { FastifyAdapter } from '@bull-board/fastify'; @@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type Logger from '@/logger.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; @@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' }; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; +import { ClientLoggerService } from './ClientLoggerService.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`; @Injectable() export class ClientServerService { + private logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -85,6 +90,7 @@ export class ClientServerService { private urlPreviewService: UrlPreviewService, private feedService: FeedService, private roleService: RoleService, + private clientLoggerService: ClientLoggerService, @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @@ -649,6 +655,24 @@ export class ClientServerService { return await renderBase(reply); }); + fastify.setErrorHandler(async (error, request, reply) => { + const errId = uuid(); + this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, { + path: request.routerPath, + params: request.params, + query: request.query, + code: error.name, + stack: error.stack, + id: errId, + }); + reply.code(500); + reply.header('Cache-Control', 'max-age=10, must-revalidate'); + return await reply.view('error', { + code: error.code, + id: errId, + }); + }); + done(); } } diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css new file mode 100644 index 0000000000..ab913f7a9f --- /dev/null +++ b/packages/backend/src/server/web/error.css @@ -0,0 +1,110 @@ +* { + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; +} + +#misskey_app, +#splash { + display: none !important; +} + +body, +html { + background-color: #222; + color: #dfddcc; + justify-content: center; + margin: auto; + padding: 10px; + text-align: center; +} + +button { + border-radius: 999px; + padding: 0px 12px 0px 12px; + border: none; + cursor: pointer; + margin-bottom: 12px; +} + +.button-big { + background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); + line-height: 50px; +} + +.button-big:hover { + background: rgb(153, 204, 0); +} + +.button-small { + background: #444; + line-height: 40px; +} + +.button-small:hover { + background: #555; +} + +.button-label-big { + color: #222; + font-weight: bold; + font-size: 20px; + padding: 12px; +} + +.button-label-small { + color: rgb(153, 204, 0); + font-size: 16px; + padding: 12px; +} + +a { + color: rgb(134, 179, 0); + text-decoration: none; +} + +p, +li { + font-size: 16px; +} + +.dont-worry, +#msg { + font-size: 18px; +} + +.icon-warning { + color: #dec340; + height: 4rem; + padding-top: 2rem; +} + +h1 { + font-size: 32px; +} + +code { + display: block; + font-family: Fira, FiraCode, monospace; + background: #333; + padding: 0.5rem 1rem; + max-width: 40rem; + border-radius: 10px; + justify-content: center; + margin: auto; + white-space: pre-wrap; + word-break: break-word; +} + +summary { + cursor: pointer; +} + +summary > * { + display: inline; + white-space: pre-wrap; +} + +@media screen and (max-width: 500px) { + details { + width: 50%; + } +} \ No newline at end of file diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug new file mode 100644 index 0000000000..b177ae4110 --- /dev/null +++ b/packages/backend/src/server/web/views/error.pug @@ -0,0 +1,65 @@ +doctype html + +// + - + _____ _ _ + | |_|___ ___| |_ ___ _ _ + | | | | |_ -|_ -| '_| -_| | | + |_|_|_|_|___|___|_,_|___|_ | + |___| + Thank you for using Misskey! + If you are reading this message... how about joining the development? + https://github.com/misskey-dev/misskey + + +html + + head + meta(charset='utf-8') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + + title + block title + = 'An error has occurred... | Misskey' + + style + include ../error.css + +body + svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round") + path(stroke="none", d="M0 0h24v24H0z", fill="none") + path(d="M12 9v2m0 4v.01") + path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75") + + h1 An error has occurred! + + button.button-big(onclick="location.reload();") + span.button-label-big Refresh + + p.dont-worry Don't worry, it's (probably) not your fault. + + p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. + + div#errors + code. + ERROR CODE: #{code} + ERROR ID: #{id} + + p You may also try the following options: + + p Update your os and browser. + p Disable an adblocker. + + a(href="/flush") + button.button-small + span.button-label-small Clear preferences and cache + br + a(href="/cli") + button.button-small + span.button-label-small Start the simple client + br + a(href="/bios") + button.button-small + span.button-label-small Start the repair tool diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts new file mode 100644 index 0000000000..bc3455e346 --- /dev/null +++ b/packages/backend/test/e2e/users.ts @@ -0,0 +1,868 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { inspect } from 'node:util'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { + signup, + post, + page, + role, + startServer, + api, + successfulApiCall, + failedApiCall, + uploadFile, +} from '../utils.js'; +import type * as misskey from 'misskey-js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('ユーザー', () => { + // エンティティとしてのユーザーを主眼においたテストを記述する + // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) + + const stripUndefined = (orig: T): Partial => { + return Object.entries({ ...orig }) + .filter(([, value]) => value !== undefined) + .reduce((obj: Partial, [key, value]) => { + obj[key as keyof T] = value; + return obj; + }, {}); + }; + + // FIXME: 足りないキーがたくさんある + type UserLite = misskey.entities.UserLite & { + badgeRoles: any[], + }; + + type UserDetailedNotMe = UserLite & + misskey.entities.UserDetailed & { + roles: any[], + }; + + type MeDetailed = UserDetailedNotMe & + misskey.entities.MeDetailed & { + showTimelineReplies: boolean, + achievements: object[], + loggedInDays: number, + policies: object, + }; + + type User = MeDetailed & { token: string }; + + const show = async (id: string, me = alice): Promise => { + return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; + }; + + const userLite = (user: User): Partial => { + return stripUndefined({ + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurhash, + isBot: user.isBot, + isCat: user.isCat, + instance: user.instance, + emojis: user.emojis, + onlineStatus: user.onlineStatus, + badgeRoles: user.badgeRoles, + + // BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。 + isAdmin: undefined, + isModerator: undefined, + }); + }; + + const userDetailedNotMe = (user: User): Partial => { + return stripUndefined({ + ...userLite(user), + url: user.url, + uri: user.uri, + movedToUri: user.movedToUri, + alsoKnownAs: user.alsoKnownAs, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastFetchedAt: user.lastFetchedAt, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + description: user.description, + location: user.location, + birthday: user.birthday, + lang: user.lang, + fields: user.fields, + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: user.pinnedNoteIds, + pinnedNotes: user.pinnedNotes, + pinnedPageId: user.pinnedPageId, + pinnedPage: user.pinnedPage, + publicReactions: user.publicReactions, + ffVisibility: user.ffVisibility, + twoFactorEnabled: user.twoFactorEnabled, + usePasswordLessLogin: user.usePasswordLessLogin, + securityKeys: user.securityKeys, + roles: user.roles, + }); + }; + + const userDetailedNotMeWithRelations = (user: User): Partial => { + return stripUndefined({ + ...userDetailedNotMe(user), + isFollowing: user.isFollowing ?? false, + isFollowed: user.isFollowed ?? false, + hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false, + hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false, + isBlocking: user.isBlocking ?? false, + isBlocked: user.isBlocked ?? false, + isMuted: user.isMuted ?? false, + isRenoteMuted: user.isRenoteMuted ?? false, + }); + }; + + const meDetailed = (user: User, security = false): Partial => { + return stripUndefined({ + ...userDetailedNotMe(user), + avatarId: user.avatarId, + bannerId: user.bannerId, + isModerator: user.isModerator, + isAdmin: user.isAdmin, + injectFeaturedNote: user.injectFeaturedNote, + receiveAnnouncementEmail: user.receiveAnnouncementEmail, + alwaysMarkNsfw: user.alwaysMarkNsfw, + autoSensitive: user.autoSensitive, + carefulBot: user.carefulBot, + autoAcceptFollowed: user.autoAcceptFollowed, + noCrawle: user.noCrawle, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, + hasUnreadMentions: user.hasUnreadMentions, + hasUnreadAnnouncement: user.hasUnreadAnnouncement, + hasUnreadAntenna: user.hasUnreadAntenna, + hasUnreadChannel: user.hasUnreadChannel, + hasUnreadNotification: user.hasUnreadNotification, + hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, + mutedWords: user.mutedWords, + mutedInstances: user.mutedInstances, + mutingNotificationTypes: user.mutingNotificationTypes, + emailNotificationTypes: user.emailNotificationTypes, + showTimelineReplies: user.showTimelineReplies, + achievements: user.achievements, + loggedInDays: user.loggedInDays, + policies: user.policies, + ...(security ? { + email: user.email, + emailVerified: user.emailVerified, + securityKeysList: user.securityKeysList, + } : {}), + }); + }; + + let app: INestApplicationContext; + + let root: User; + let alice: User; + let aliceNote: misskey.entities.Note; + let alicePage: misskey.entities.Page; + let aliceList: misskey.entities.UserList; + + let bob: User; + let bobNote: misskey.entities.Note; + + let carol: User; + let dave: User; + let ellen: User; + let frank: User; + + let usersReplying: User[]; + + let userNoNote: User; + let userNotExplorable: User; + let userLocking: User; + let userAdmin: User; + let roleAdmin: any; + let userModerator: User; + let roleModerator: any; + let userRolePublic: User; + let rolePublic: any; + let userRoleBadge: User; + let roleBadge: any; + let userSilenced: User; + let roleSilenced: any; + let userSuspended: User; + let userDeletedBySelf: User; + let userDeletedByAdmin: User; + let userFollowingAlice: User; + let userFollowedByAlice: User; + let userBlockingAlice: User; + let userBlockedByAlice: User; + let userMutingAlice: User; + let userMutedByAlice: User; + let userRnMutingAlice: User; + let userRnMutedByAlice: User; + let userFollowRequesting: User; + let userFollowRequested: User; + + beforeAll(async () => { + app = await startServer(); + }, 1000 * 60 * 2); + + beforeAll(async () => { + root = await signup({ username: 'alice' }); + alice = root; + aliceNote = await post(alice, { text: 'test' }) as any; + alicePage = await page(alice); + aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; + bob = await signup({ username: 'bob' }); + bobNote = await post(bob, { text: 'test' }) as any; + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + ellen = await signup({ username: 'ellen' }); + frank = await signup({ username: 'frank' }); + + // @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする + usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => { + const u = await signup({ username: `replying${i}` }); + for (let j = 0; j < 10 - i; j++) { + const p = await post(u, { text: `test${j}` }); + await post(alice, { text: `@${u.username} test${j}`, replyId: p.id }); + } + + return (await acc).concat(u); + }, Promise.resolve([] as User[])); + + userNoNote = await signup({ username: 'userNoNote' }); + userNotExplorable = await signup({ username: 'userNotExplorable' }); + await post(userNotExplorable, { text: 'test' }); + await api('i/update', { isExplorable: false }, userNotExplorable); + userLocking = await signup({ username: 'userLocking' }); + await post(userLocking, { text: 'test' }); + await api('i/update', { isLocked: true }, userLocking); + userAdmin = await signup({ username: 'userAdmin' }); + roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' }); + await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root); + userModerator = await signup({ username: 'userModerator' }); + roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' }); + await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root); + userRolePublic = await signup({ username: 'userRolePublic' }); + rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); + await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); + userRoleBadge = await signup({ username: 'userRoleBadge' }); + roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); + await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); + userSilenced = await signup({ username: 'userSilenced' }); + await post(userSilenced, { text: 'test' }); + roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); + await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); + userSuspended = await signup({ username: 'userSuspended' }); + await post(userSuspended, { text: 'test' }); + await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); + await api('admin/suspend-user', { userId: userSuspended.id }, root); + userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); + await post(userDeletedBySelf, { text: 'test' }); + await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); + userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); + await post(userDeletedByAdmin, { text: 'test' }); + await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); + userFollowingAlice = await signup({ username: 'userFollowingAlice' }); + await post(userFollowingAlice, { text: 'test' }); + await api('following/create', { userId: alice.id }, userFollowingAlice); + userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); + await post(userFollowedByAlice, { text: 'test' }); + await api('following/create', { userId: userFollowedByAlice.id }, alice); + userBlockingAlice = await signup({ username: 'userBlockingAlice' }); + await post(userBlockingAlice, { text: 'test' }); + await api('blocking/create', { userId: alice.id }, userBlockingAlice); + userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); + await post(userBlockedByAlice, { text: 'test' }); + await api('blocking/create', { userId: userBlockedByAlice.id }, alice); + userMutingAlice = await signup({ username: 'userMutingAlice' }); + await post(userMutingAlice, { text: 'test' }); + await api('mute/create', { userId: alice.id }, userMutingAlice); + userMutedByAlice = await signup({ username: 'userMutedByAlice' }); + await post(userMutedByAlice, { text: 'test' }); + await api('mute/create', { userId: userMutedByAlice.id }, alice); + userRnMutingAlice = await signup({ username: 'userRnMutingAlice' }); + await post(userRnMutingAlice, { text: 'test' }); + await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice); + userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' }); + await post(userRnMutedByAlice, { text: 'test' }); + await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice); + userFollowRequesting = await signup({ username: 'userFollowRequesting' }); + await post(userFollowRequesting, { text: 'test' }); + userFollowRequested = userLocking; + await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting); + }, 1000 * 60 * 10); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = { + ...alice, + ...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any, + }; + aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice }); + }); + + //#region サインアップ(signup) + + test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => { + // SignupApiService.ts + const response = await successfulApiCall({ + endpoint: 'signup', + parameters: { username: 'zoe', password: 'password' }, + user: undefined, + }) as unknown as User; // BUG MeDetailedに足りないキーがある + + // signupの時はtokenが含まれる特別なMeDetailedが返ってくる + assert.match(response.token, /[a-zA-Z0-9]{16}/); + + // UserLite + assert.match(response.id, /[0-9a-z]{10}/); + assert.strictEqual(response.name, null); + assert.strictEqual(response.username, 'zoe'); + assert.strictEqual(response.host, null); + assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.strictEqual(response.avatarBlurhash, null); + assert.strictEqual(response.isBot, false); + assert.strictEqual(response.isCat, false); + assert.strictEqual(response.instance, undefined); + assert.deepStrictEqual(response.emojis, {}); + assert.strictEqual(response.onlineStatus, 'unknown'); + assert.deepStrictEqual(response.badgeRoles, []); + // UserDetailedNotMeOnly + assert.strictEqual(response.url, null); + assert.strictEqual(response.uri, null); + assert.strictEqual(response.movedToUri, null); + assert.strictEqual(response.alsoKnownAs, null); + assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); + assert.strictEqual(response.updatedAt, null); + assert.strictEqual(response.lastFetchedAt, null); + assert.strictEqual(response.bannerUrl, null); + assert.strictEqual(response.bannerBlurhash, null); + assert.strictEqual(response.isLocked, false); + assert.strictEqual(response.isSilenced, false); + assert.strictEqual(response.isSuspended, false); + assert.strictEqual(response.description, null); + assert.strictEqual(response.location, null); + assert.strictEqual(response.birthday, null); + assert.strictEqual(response.lang, null); + assert.deepStrictEqual(response.fields, []); + assert.strictEqual(response.followersCount, 0); + assert.strictEqual(response.followingCount, 0); + assert.strictEqual(response.notesCount, 0); + assert.deepStrictEqual(response.pinnedNoteIds, []); + assert.deepStrictEqual(response.pinnedNotes, []); + assert.strictEqual(response.pinnedPageId, null); + assert.strictEqual(response.pinnedPage, null); + assert.strictEqual(response.publicReactions, false); + assert.strictEqual(response.ffVisibility, 'public'); + assert.strictEqual(response.twoFactorEnabled, false); + assert.strictEqual(response.usePasswordLessLogin, false); + assert.strictEqual(response.securityKeys, false); + assert.deepStrictEqual(response.roles, []); + + // MeDetailedOnly + assert.strictEqual(response.avatarId, null); + assert.strictEqual(response.bannerId, null); + assert.strictEqual(response.isModerator, false); + assert.strictEqual(response.isAdmin, false); + assert.strictEqual(response.injectFeaturedNote, true); + assert.strictEqual(response.receiveAnnouncementEmail, true); + assert.strictEqual(response.alwaysMarkNsfw, false); + assert.strictEqual(response.autoSensitive, false); + assert.strictEqual(response.carefulBot, false); + assert.strictEqual(response.autoAcceptFollowed, true); + assert.strictEqual(response.noCrawle, false); + assert.strictEqual(response.isExplorable, true); + assert.strictEqual(response.isDeleted, false); + assert.strictEqual(response.hideOnlineStatus, false); + assert.strictEqual(response.hasUnreadSpecifiedNotes, false); + assert.strictEqual(response.hasUnreadMentions, false); + assert.strictEqual(response.hasUnreadAnnouncement, false); + assert.strictEqual(response.hasUnreadAntenna, false); + assert.strictEqual(response.hasUnreadChannel, false); + assert.strictEqual(response.hasUnreadNotification, false); + assert.strictEqual(response.hasPendingReceivedFollowRequest, false); + assert.deepStrictEqual(response.mutedWords, []); + assert.deepStrictEqual(response.mutedInstances, []); + assert.deepStrictEqual(response.mutingNotificationTypes, []); + assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); + assert.strictEqual(response.showTimelineReplies, false); + assert.deepStrictEqual(response.achievements, []); + assert.deepStrictEqual(response.loggedInDays, 0); + assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.notStrictEqual(response.email, undefined); + assert.strictEqual(response.emailVerified, false); + assert.deepStrictEqual(response.securityKeysList, []); + }); + + //#endregion + //#region 自分の情報(i) + + test('を読み取ることができる。(自分)', async () => { + const response = await successfulApiCall({ + endpoint: 'i', + parameters: {}, + user: userNoNote, + }); + const expected = meDetailed(userNoNote, true); + expected.loggedInDays = 1; // iはloggedInDaysを更新する + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region 自分の情報の更新(i/update) + + test.each([ + { parameters: (): object => ({ name: null }) }, + { parameters: (): object => ({ name: 'x'.repeat(50) }) }, + { parameters: (): object => ({ name: 'x' }) }, + { parameters: (): object => ({ name: 'My name' }) }, + { parameters: (): object => ({ description: null }) }, + { parameters: (): object => ({ description: 'x'.repeat(1500) }) }, + { parameters: (): object => ({ description: 'x' }) }, + { parameters: (): object => ({ description: 'My description' }) }, + { parameters: (): object => ({ location: null }) }, + { parameters: (): object => ({ location: 'x'.repeat(50) }) }, + { parameters: (): object => ({ location: 'x' }) }, + { parameters: (): object => ({ location: 'My location' }) }, + { parameters: (): object => ({ birthday: '0000-00-00' }) }, + { parameters: (): object => ({ birthday: '9999-99-99' }) }, + { parameters: (): object => ({ lang: 'en-US' }) }, + { parameters: (): object => ({ fields: [] }) }, + { parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) }, + { parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない + { parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) }, + { parameters: (): object => ({ isLocked: true }) }, + { parameters: (): object => ({ isLocked: false }) }, + { parameters: (): object => ({ isExplorable: false }) }, + { parameters: (): object => ({ isExplorable: true }) }, + { parameters: (): object => ({ hideOnlineStatus: true }) }, + { parameters: (): object => ({ hideOnlineStatus: false }) }, + { parameters: (): object => ({ publicReactions: false }) }, + { parameters: (): object => ({ publicReactions: true }) }, + { parameters: (): object => ({ autoAcceptFollowed: true }) }, + { parameters: (): object => ({ autoAcceptFollowed: false }) }, + { parameters: (): object => ({ noCrawle: true }) }, + { parameters: (): object => ({ noCrawle: false }) }, + { parameters: (): object => ({ isBot: true }) }, + { parameters: (): object => ({ isBot: false }) }, + { parameters: (): object => ({ isCat: true }) }, + { parameters: (): object => ({ isCat: false }) }, + { parameters: (): object => ({ showTimelineReplies: true }) }, + { parameters: (): object => ({ showTimelineReplies: false }) }, + { parameters: (): object => ({ injectFeaturedNote: true }) }, + { parameters: (): object => ({ injectFeaturedNote: false }) }, + { parameters: (): object => ({ receiveAnnouncementEmail: true }) }, + { parameters: (): object => ({ receiveAnnouncementEmail: false }) }, + { parameters: (): object => ({ alwaysMarkNsfw: true }) }, + { parameters: (): object => ({ alwaysMarkNsfw: false }) }, + { parameters: (): object => ({ autoSensitive: true }) }, + { parameters: (): object => ({ autoSensitive: false }) }, + { parameters: (): object => ({ ffVisibility: 'private' }) }, + { parameters: (): object => ({ ffVisibility: 'followers' }) }, + { parameters: (): object => ({ ffVisibility: 'public' }) }, + { parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) }, + { parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) }, + { parameters: (): object => ({ mutedWords: [] }) }, + { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, + { parameters: (): object => ({ mutedInstances: [] }) }, + { parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, + { parameters: (): object => ({ mutingNotificationTypes: [] }) }, + { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, + { parameters: (): object => ({ emailNotificationTypes: [] }) }, + ] as const)('を書き換えることができる($#)', async ({ parameters }) => { + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice }); + const expected = { ...meDetailed(alice, true), ...parameters() }; + assert.deepStrictEqual(response, expected, inspect(parameters())); + }); + + test('を書き換えることができる(Avatar)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { avatarId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + avatarId: aliceFile.id, + avatarBlurhash: response.avatarBlurhash, + avatarUrl: response.avatarUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + if (1) return; // BUG 521eb95 以降アバターのリセットができない。 + const parameters2 = { avatarId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + avatarId: null, + avatarBlurhash: null, + avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + + test('を書き換えることができる(Banner)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { bannerId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + bannerId: aliceFile.id, + bannerBlurhash: response.bannerBlurhash, + bannerUrl: response.bannerUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + if (1) return; // BUG 521eb95 以降バナーのリセットができない。 + const parameters2 = { bannerId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + bannerId: null, + bannerBlurhash: null, + bannerUrl: null, + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + + //#endregion + //#region 自分の情報の更新(i/pin, i/unpin) + + test('を書き換えることができる(ピン止めノート)', async () => { + const parameters = { noteId: aliceNote.id }; + const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice }); + const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] }; + assert.deepStrictEqual(response, expected); + + const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice }); + const expected2 = meDetailed(alice, false); + assert.deepStrictEqual(response2, expected2); + }); + + //#endregion + //#region ユーザー(users) + + test.each([ + { label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id }, + { label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + { label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + ] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => { + const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); + + // 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する + const users = await Promise.all(response.map(u => show(u.id))); + const expected = users.sort((x, y) => { + const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; + return index * (parameters.sort?.startsWith('+') ? -1 : 1); + }); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true }, + { label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true }, + { label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => { + const parameters = { limit: 100 }; + const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected); + }); + test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); + test.todo('をリスト形式で取得することができる(pagenation)'); + + //#endregion + //#region ユーザー情報(users/show) + + test.each([ + { label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed }, + { label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations }, + { label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe }, + { label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed }, + { label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations }, + { label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe }, + ] as const)('を取得することができる($label)', async ({ parameters, user, type }) => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() }); + const expected = type(alice); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin }, + { label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined }, + { label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator }, + { label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined }, + { label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced }, + { label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, + { label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted }, + { label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, + { label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted }, + { label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, + { label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing }, + { label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed }, + { label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking }, + { label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked }, + { label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted }, + { label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted }, + { label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou }, + { label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou }, + ] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice }); + assert.strictEqual(selector(response), (expected ?? ((): true => true))()); + }); + test('を取得することができ、Publicなロールがセットされていること', async () => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice }); + assert.deepStrictEqual(response.badgeRoles, []); + assert.deepStrictEqual(response.roles, [{ + id: rolePublic.id, + name: rolePublic.name, + color: rolePublic.color, + iconUrl: rolePublic.iconUrl, + description: rolePublic.description, + isModerator: rolePublic.isModerator, + isAdministrator: rolePublic.isAdministrator, + displayOrder: rolePublic.displayOrder, + }]); + }); + test('を取得することができ、バッヂロールがセットされていること', async () => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); + assert.deepStrictEqual(response.badgeRoles, [{ + name: roleBadge.name, + iconUrl: roleBadge.iconUrl, + displayOrder: roleBadge.displayOrder, + }]); + assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない + }); + test('をID指定のリスト形式で取得することができる(空)', async () => { + const parameters = { userIds: [] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); + const expected: [] = []; + assert.deepStrictEqual(response, expected); + }); + test('をID指定のリスト形式で取得することができる', async() => { + const parameters = { userIds: [bob.id, alice.id, carol.id] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); + const expected = [ + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), + ]; + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root }, + // BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる + //{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => { + const parameters = { userIds: [user().id] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をID指定のリスト形式で取得することができる(リモート)'); + + //#endregion + //#region 検索(users/search) + + test('を検索することができる', async () => { + const parameters = { query: 'carol', limit: 10 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = [await show(carol.id)]; + assert.deepStrictEqual(response, expected); + }); + test('を検索することができる(UserLite)', async () => { + const parameters = { query: 'carol', detail: false, limit: 10 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = [userLite(await show(carol.id))]; + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { + const parameters = { query: user().username, limit: 1 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('を検索することができる(リモート)'); + test.todo('を検索することができる(pagenation)'); + + //#endregion + //#region ID指定検索(users/search-by-username-and-host) + + test.each([ + { label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] }, + { label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] }, + { label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] }, + { label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] }, + { label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] }, + { label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] }, + { label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] }, + { label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] }, + { label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] }, + ])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => { + const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); + const expected = await Promise.all(user().map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => { + const parameters = { username: user().username }; + const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をID&ホスト指定で検索できる(リモート)'); + + //#endregion + //#region ID指定検索(users/get-frequently-replied-users) + + test('がよくリプライをするユーザーのリストを取得できる', async () => { + const parameters = { userId: alice.id, limit: 5 }; + const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); + const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ + user: await show(s.id), + weight: (usersReplying.length - i) / usersReplying.length, + }))); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => { + const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0]; + await post(alice, { text: `@${user().username} test`, replyId: replyTo.id }); + const parameters = { userId: alice.id, limit: 100 }; + const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected); + }); + + //#endregion + //#region ハッシュタグ(hashtags/users) + + test.each([ + { label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + { label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + ] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => { + const hashtag = 'test_hashtag'; + await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice }); + const parameters = { tag: hashtag, limit: 5, ...sort }; + const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); + const users = await Promise.all(response.map(u => show(u.id))); + const expected = users.sort((x, y) => { + const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; + return index * (parameters.sort.startsWith('+') ? -1 : 1); + }); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => { + const hashtag = `user_test${user().username}`; + if (user() !== userSuspended) { + // サスペンドユーザーはupdateできない。 + await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() }); + } + const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const; + const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); + const expected = [await show(user().id)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をハッシュタグ指定で取得することができる(リモート)'); + + //#endregion + //#region オススメユーザー(users/recommendation) + + // BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note" + test.skip('のオススメを取得することができる', async () => { + const parameters = {}; + const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice }); + const expected = await Promise.all(response.map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region ピン止めユーザー(pinned-users) + + test('のピン止めユーザーを取得することができる', async () => { + await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root }); + const parameters = {} as const; + const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice }); + const expected = await Promise.all([bob, carol].map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + + test.todo('を管理人として確認することができる(admin/show-user)'); + test.todo('を管理人として確認することができる(admin/show-users)'); + test.todo('をサーバー向けに取得することができる(federation/users)'); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 4f501a8726..809ed2c66c 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -6,6 +6,7 @@ import WebSocket from 'ws'; import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; import { JSDOM } from 'jsdom'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; @@ -31,12 +32,12 @@ export type ApiRequest = { }; export const successfulApiCall = async (request: ApiRequest, assertion: { - status: number, -} = { status: 200 }): Promise => { + status?: number, +} = {}): Promise => { const { endpoint, parameters, user } = request; - const { status } = assertion; const res = await api(endpoint, parameters, user); - assert.strictEqual(res.status, status, inspect(res.body)); + const status = assertion.status ?? (res.body == null ? 204 : 200); + assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); return res.body; }; @@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise => { return res.body; }; +export const role = async (user: any, role: any = {}, policies: any = {}): Promise => { + const res = await api('admin/roles/create', { + asBadge: false, + canEditMembersByModerator: false, + color: null, + condFormula: { + id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85', + type: 'isRemote', + }, + description: '', + displayOrder: 0, + iconUrl: null, + isAdministrator: false, + isModerator: false, + isPublic: false, + name: 'New Role', + target: 'manual', + policies: { + ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { + priority: 0, + useDefault: true, + value: v, + }]), + ...policies, + }, + ...role, + }, user); + return res.body; +}; + interface UploadOptions { /** Optional, absolute path or relative from ./resources/ */ path?: string | URL; diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 3e9c048fae..9340560914 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -118,7 +118,7 @@ function toStories(component: string): string { .replace(/[-.]|^(?=\d)/g, '_') .replace(/(?<=^[^A-Z_]*$)/, '_')} /> as estree.Identifier; - const parameters = ( + const parameters = - ) as estree.ObjectExpression; - const program = ( + /> as estree.ObjectExpression; + const program = ) as estree.Identifier} /> as estree.ExportDefaultDeclaration, ]} - /> - ) as estree.Program; + /> as estree.Program; return format( '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + '/* eslint-disable import/no-default-export */\n' + + '/* eslint-disable import/no-duplicates */\n' + generate(program, { generator }) + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), { @@ -397,7 +396,7 @@ function toStories(component: string): string { // glob('src/{components,pages,ui,widgets}/**/*.vue') Promise.all([ glob('src/components/global/*.vue'), - glob('src/components/MkAnalogClock.vue'), + glob('src/components/Mk{A,B}*.vue'), glob('src/components/MkDigitalClock.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/pages/user/home.vue'), diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts index 41c3c5c4d9..4091e39686 100644 --- a/packages/frontend/.storybook/mocks.ts +++ b/packages/frontend/.storybook/mocks.ts @@ -8,6 +8,16 @@ export const onUnhandledRequest = ((req, print) => { }) satisfies SharedOptions['onUnhandledRequest']; export const commonHandlers = [ + rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), + rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { const { codepoints } = req.params; const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index 64e537b931..ab694f64fb 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -1,3 +1,5 @@ + + diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 1834224b8d..a6372b7b6f 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -82,6 +82,7 @@ export default defineComponent({ omitted: null, ignoreOmit: false, defaultStore, + i18n, }; }, mounted() { diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 0ae182ce32..9eaf16374b 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -439,7 +439,6 @@ defineExpose({ &.asDrawer { width: 100% !important; - padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0; > .emojis { ::v-deep(section) { @@ -498,6 +497,10 @@ defineExpose({ background: transparent; color: var(--fg); + &:not(:focus):not(.filled) { + margin-bottom: env(safe-area-inset-bottom, 0px); + } + &:not(.filled) { order: 1; z-index: 2; diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 1576144f6b..5ca4c50518 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -31,7 +31,7 @@ import { onMounted } from 'vue'; import * as misskey from 'misskey-js'; import VuePlyr from 'vue-plyr'; -import { ColdDeviceStorage } from '@/store'; +import { soundConfigStore } from '@/scripts/sound'; import 'vue-plyr/dist/vue-plyr.css'; import { i18n } from '@/i18n'; @@ -44,11 +44,11 @@ const audioEl = $shallowRef(); let hide = $ref(true); function volumechange() { - if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume); + if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume); } onMounted(() => { - if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume'); + if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume; }); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 10cb7d96cc..42a3748d9a 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -1124,16 +1124,16 @@ defineExpose({ display: grid; grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); - grid-auto-rows: 46px; + grid-auto-rows: 40px; } .footerRight { - flex: 0.3; + flex: 0; margin-left: auto; display: grid; grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); - grid-auto-rows: 46px; + grid-auto-rows: 40px; direction: rtl; } @@ -1198,13 +1198,21 @@ defineExpose({ } } -@container (max-width: 330px) { +@container (max-width: 350px) { + .footer { + font-size: 0.9em; + } + + .footerLeft { + grid-template-columns: repeat(auto-fill, minmax(38px, 1fr)); + } + + .footerRight { + grid-template-columns: repeat(auto-fill, minmax(38px, 1fr)); + } + .headerRight { gap: 0; } - - .footer { - font-size: 14px; - } } diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 6741e7a18b..fb0a3a4b67 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -15,6 +15,7 @@ const props = defineProps<{ list?: string; antenna?: string; channel?: string; + role?: string; sound?: boolean; }>(); @@ -121,6 +122,15 @@ if (props.src === 'antenna') { channelId: props.channel, }); connection.on('note', prepend); +} else if (props.src === 'role') { + endpoint = 'roles/notes'; + query = { + roleId: props.role, + }; + connection = stream.useChannel('roleTimeline', { + roleId: props.role, + }); + connection.on('note', prepend); } const pagination = { diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 5799f99d5f..aa975600f0 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -83,7 +83,7 @@ const choseAd = (): Ad | null => { }; const chosen = ref(choseAd()); -const shouldHide = $ref($i && $i.policies.canHideAds && (props.specify == null)); +const shouldHide = $ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { if (chosen.value == null) return; diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 84aae1cff8..0cb31ffcba 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -5,7 +5,7 @@ diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index ab3d01532b..2462967515 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -296,7 +296,7 @@ $widgets-hide-threshold: 1090px; } .widgets { - padding: 0 var(--margin); + padding: 0 var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)); border-left: solid 0.5px var(--divider); background: var(--bg); @@ -329,7 +329,7 @@ $widgets-hide-threshold: 1090px; right: 0; z-index: 1001; height: 100dvh; - padding: var(--margin) !important; + padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)) !important; box-sizing: border-box; overflow: auto; overscroll-behavior: contain; diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue index 2e557d30ce..d11649c603 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -3,7 +3,7 @@ - + @@ -91,4 +91,8 @@ function updateWidgets(thisWidgets) { .widgets { width: 300px; } + +.edit { + width: 100%; +} diff --git a/packages/sw/.eslintrc.js b/packages/sw/.eslintrc.js index ae9c53244a..b1fd6b5edc 100644 --- a/packages/sw/.eslintrc.js +++ b/packages/sw/.eslintrc.js @@ -1,22 +1,20 @@ module.exports = { root: true, env: { - "node": false + node: false, }, parserOptions: { - "parser": "@typescript-eslint/parser", + parser: '@typescript-eslint/parser', tsconfigRootDir: __dirname, project: ['./tsconfig.json'], }, - extends: [ - "../shared/.eslintrc.js", - ], + extends: ['../shared/.eslintrc.js'], globals: { - "require": false, - "_DEV_": false, - "_LANGS_": false, - "_VERSION_": false, - "_ENV_": false, - "_PERF_PREFIX_": false, - } -} + require: false, + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _ENV_: false, + _PERF_PREFIX_: false, + }, +}; diff --git a/packages/sw/build.js b/packages/sw/build.js index 72d9db9c0f..ad16fb9497 100644 --- a/packages/sw/build.js +++ b/packages/sw/build.js @@ -1,3 +1,5 @@ +// @ts-check + const esbuild = require('esbuild'); const locales = require('../../locales'); const meta = require('../../package.json'); @@ -5,33 +7,36 @@ const watch = process.argv[2]?.includes('watch'); console.log('Starting SW building...'); -esbuild.build({ - entryPoints: [ `${__dirname}/src/sw.ts` ], - bundle: true, - format: 'esm', - treeShaking: true, - minify: process.env.NODE_ENV === 'production', +/** @type {esbuild.BuildOptions} */ +const buildOptions = { absWorkingDir: __dirname, + bundle: true, + define: { + _DEV_: JSON.stringify(process.env.NODE_ENV !== 'production'), + _ENV_: JSON.stringify(process.env.NODE_ENV ?? ''), // `NODE_ENV`が`undefined`なとき`JSON.stringify`が`undefined`を返してエラーになってしまうので`??`を使っている + _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])), + _PERF_PREFIX_: JSON.stringify('Misskey:'), + _VERSION_: JSON.stringify(meta.version), + }, + entryPoints: [`${__dirname}/src/sw.ts`], + format: 'esm', + loader: { + '.ts': 'ts', + }, + minify: process.env.NODE_ENV === 'production', outbase: `${__dirname}/src`, outdir: `${__dirname}/../../built/_sw_dist_`, - loader: { - '.ts': 'ts' - }, + treeShaking: true, tsconfig: `${__dirname}/tsconfig.json`, - define: { - _VERSION_: JSON.stringify(meta.version), - _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])), - _ENV_: JSON.stringify(process.env.NODE_ENV), - _DEV_: process.env.NODE_ENV !== 'production', - _PERF_PREFIX_: JSON.stringify('Misskey:'), - }, - watch: watch ? { - onRebuild(error, result) { - if (error) console.error('SW: watch build failed:', error); - else console.log('SW: watch build succeeded:', result); - }, - } : false, -}).then(result => { - if (watch) console.log('watching...'); - else console.log('done,', JSON.stringify(result)); -}); +}; + +(async () => { + if (!watch) { + await esbuild.build(buildOptions); + console.log('done'); + } else { + const context = await esbuild.context(buildOptions); + await context.watch(); + console.log('watching...'); + } +})(); diff --git a/packages/sw/package.json b/packages/sw/package.json index f82f186619..d7a4a2a4b6 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -9,7 +9,7 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "esbuild": "0.14.42", + "esbuild": "0.17.15", "idb-keyval": "6.2.0", "misskey-js": "workspace:*" }, diff --git a/packages/sw/src/@types/global.d.ts b/packages/sw/src/@types/global.d.ts index 5aaef9412c..a7d176b0b8 100644 --- a/packages/sw/src/@types/global.d.ts +++ b/packages/sw/src/@types/global.d.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any type FIXME = any; declare const _LANGS_: string[][]; diff --git a/packages/sw/src/filters/user.ts b/packages/sw/src/filters/user.ts deleted file mode 100644 index 09437eb19a..0000000000 --- a/packages/sw/src/filters/user.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as misskey from 'misskey-js'; -import * as Acct from 'misskey-js/built/acct'; - -export const acct = (user: misskey.Acct) => { - return Acct.toString(user); -}; - -export const userName = (user: misskey.entities.User) => { - return user.name || user.username; -}; - -export const userPage = (user: misskey.Acct, path?, absolute = false) => { - return `${absolute ? origin : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; -}; diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index 6286d076c7..61ffa7ab0f 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -1,31 +1,29 @@ /* * Notification manager for SW */ -import { swLang } from '@/scripts/lang'; -import { cli } from '@/scripts/operations'; -import { badgeNames, pushNotificationDataMap } from '@/types'; -import getUserName from '@/scripts/get-user-name'; -import { I18n } from '@/scripts/i18n'; -import { getAccountFromId } from '@/scripts/get-account-from-id'; +import type { BadgeNames, PushNotificationDataMap } from '@/types'; import { char2fileName } from '@/scripts/twemoji-base'; -import * as url from '@/scripts/url'; +import { cli } from '@/scripts/operations'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { swLang } from '@/scripts/lang'; +import { getUserName } from '@/scripts/get-user-name'; -const closeNotificationsByTags = async (tags: string[]) => { +const closeNotificationsByTags = async (tags: string[]): Promise => { for (const n of (await Promise.all(tags.map(tag => globalThis.registration.getNotifications({ tag })))).flat()) { n.close(); } }; -const iconUrl = (name: badgeNames) => `/static-assets/tabler-badges/${name}.png`; +const iconUrl = (name: BadgeNames): string => `/static-assets/tabler-badges/${name}.png`; /* How to add a new badge: * 1. Find the icon and download png from https://tabler-icons.io/ * 2. vips resize ~/Downloads/icon-name.png vipswork.png 0.4; vips scRGB2BW vipswork.png ~/icon-name.png"[compression=9,strip]"; rm vipswork.png; * 3. mv ~/icon-name.png ~/misskey/packages/backend/assets/tabler-badges/ - * 4. Add 'icon-name' to badgeNames + * 4. Add 'icon-name' to BadgeNames * 5. Add `badge: iconUrl('icon-name'),` */ -export async function createNotification(data: pushNotificationDataMap[K]) { +export async function createNotification(data: PushNotificationDataMap[K]): Promise { const n = await composeNotification(data); if (n) { @@ -36,9 +34,8 @@ export async function createNotification { - if (!swLang.i18n) swLang.fetchLocale(); - const i18n = await swLang.i18n as I18n; +async function composeNotification(data: PushNotificationDataMap[keyof PushNotificationDataMap]): Promise<[string, NotificationOptions] | null> { + const i18n = await (swLang.i18n ?? swLang.fetchLocale()); const { t } = i18n; switch (data.type) { /* @@ -139,16 +136,16 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif if (reaction.startsWith(':')) { // カスタム絵文字の場合 const name = reaction.substring(1, reaction.length - 1); - badge = `${origin}/emoji/${name}.webp?${url.query({ - badge: '1', - })}`; + const badgeUrl = new URL(`/emoji/${name}.webp`, origin); + badgeUrl.searchParams.set('badge', '1'); + badge = badgeUrl.href; reaction = name.split('@')[0]; } else { // Unicode絵文字の場合 badge = `/twemoji-badge/${char2fileName(reaction)}.png`; } - if (badge ? await fetch(badge).then(res => res.status !== 200).catch(() => true) : true) { + if (await fetch(badge).then(res => res.status !== 200).catch(() => true)) { badge = iconUrl('plus'); } @@ -168,14 +165,6 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif }]; } - case 'pollEnded': - return [t('_notification.pollEnded'), { - body: data.body.note.text || '', - badge: iconUrl('chart-arrows'), - tag: `poll:${data.body.note.id}`, - data, - }]; - case 'receiveFollowRequest': return [t('_notification.youReceivedFollowRequest'), { body: getUserName(data.body.user), @@ -202,6 +191,14 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif data, }]; + case 'achievementEarned': + return [t('_notification.achievementEarned'), { + body: t(`_achievements._types._${data.body.achievement}.title`), + badge: iconUrl('medal'), + data, + tag: `achievement:${data.body.achievement}`, + }]; + case 'app': return [data.body.header ?? data.body.body, { body: data.body.header ? data.body.body : '', @@ -226,24 +223,35 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif } } -export async function createEmptyNotification() { +export async function createEmptyNotification(): Promise { return new Promise(async res => { - if (!swLang.i18n) swLang.fetchLocale(); - const i18n = await swLang.i18n as I18n; + const i18n = await (swLang.i18n ?? swLang.fetchLocale()); const { t } = i18n; await globalThis.registration.showNotification( - t('_notification.emptyPushNotificationMessage'), + (new URL(origin)).host, { + body: `Misskey v${_VERSION_}`, silent: true, badge: iconUrl('null'), tag: 'read_notification', + actions: [ + { + action: 'markAllAsRead', + title: t('markAllAsRead'), + }, + { + action: 'settings', + title: t('notificationSettings'), + }, + ], + data: {}, }, ); setTimeout(async () => { try { - await closeNotificationsByTags(['user_visible_auto_notification', 'read_notification']); + await closeNotificationsByTags(['user_visible_auto_notification']); } finally { res(); } diff --git a/packages/sw/src/scripts/get-account-from-id.ts b/packages/sw/src/scripts/get-account-from-id.ts index be4cfaeba4..825829009f 100644 --- a/packages/sw/src/scripts/get-account-from-id.ts +++ b/packages/sw/src/scripts/get-account-from-id.ts @@ -1,7 +1,10 @@ import { get } from 'idb-keyval'; -export async function getAccountFromId(id: string) { - const accounts = await get('accounts') as { token: string; id: string; }[]; - if (!accounts) console.log('Accounts are not recorded'); +export async function getAccountFromId(id: string): Promise<{ token: string; id: string } | void> { + const accounts = await get<{ token: string; id: string }[]>('accounts'); + if (!accounts) { + console.log('Accounts are not recorded'); + return; + } return accounts.find(e => e.id === id); } diff --git a/packages/sw/src/scripts/get-user-name.ts b/packages/sw/src/scripts/get-user-name.ts index 4daf203e06..e7e75ee25f 100644 --- a/packages/sw/src/scripts/get-user-name.ts +++ b/packages/sw/src/scripts/get-user-name.ts @@ -1,3 +1,3 @@ -export default function(user: { name?: string | null, username: string }): string { +export function getUserName(user: { name?: string | null; username: string }): string { return user.name === '' ? user.username : user.name ?? user.username; } diff --git a/packages/sw/src/scripts/i18n.ts b/packages/sw/src/scripts/i18n.ts index 3fe88e5514..33e8e4267b 100644 --- a/packages/sw/src/scripts/i18n.ts +++ b/packages/sw/src/scripts/i18n.ts @@ -1,4 +1,6 @@ -export class I18n> { +export type Locale = { [key: string]: string | Locale }; + +export class I18n { public ts: T; constructor(locale: T) { @@ -13,7 +15,8 @@ export class I18n> { // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも public t(key: string, args?: Record): string { try { - let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + let str = key.split('.').reduce((o, i) => o[i], this.ts); + if (typeof str !== 'string') throw new Error(); if (args) { for (const [k, v] of Object.entries(args)) { diff --git a/packages/sw/src/scripts/lang.ts b/packages/sw/src/scripts/lang.ts index 39bd333aba..063059908e 100644 --- a/packages/sw/src/scripts/lang.ts +++ b/packages/sw/src/scripts/lang.ts @@ -2,7 +2,7 @@ * Language manager for SW */ import { get, set } from 'idb-keyval'; -import { I18n } from '@/scripts/i18n'; +import { I18n, type Locale } from '@/scripts/i18n'; class SwLang { public cacheName = `mk-cache-${_VERSION_}`; @@ -12,19 +12,19 @@ class SwLang { return prelang; }); - public setLang(newLang: string) { + public setLang(newLang: string): Promise> { this.lang = Promise.resolve(newLang); set('lang', newLang); return this.fetchLocale(); } - public i18n: Promise> | null = null; + public i18n: Promise | null = null; - public fetchLocale() { - return this.i18n = this._fetch(); + public fetchLocale(): Promise> { + return (this.i18n = this._fetch()); } - private async _fetch() { + private async _fetch(): Promise> { // Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`; let localeRes = await caches.match(localeUrl); @@ -32,13 +32,13 @@ class SwLang { // _DEV_がtrueの場合は常に最新化 if (!localeRes || _DEV_) { localeRes = await fetch(localeUrl); - const clone = localeRes?.clone(); - if (!clone?.clone().ok) Error('locale fetching error'); + const clone = localeRes.clone(); + if (!clone.clone().ok) throw new Error('locale fetching error'); caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone)); } - return new I18n(await localeRes.json()); + return new I18n(await localeRes.json()); } } diff --git a/packages/sw/src/scripts/login-id.ts b/packages/sw/src/scripts/login-id.ts index 0f9c6be4a9..ce04afd601 100644 --- a/packages/sw/src/scripts/login-id.ts +++ b/packages/sw/src/scripts/login-id.ts @@ -1,11 +1,5 @@ -export function getUrlWithLoginId(url: string, loginId: string) { +export function getUrlWithLoginId(url: string, loginId: string): string { const u = new URL(url, origin); - u.searchParams.append('loginId', loginId); - return u.toString(); -} - -export function getUrlWithoutLoginId(url: string) { - const u = new URL(url); - u.searchParams.delete('loginId'); + u.searchParams.set('loginId', loginId); return u.toString(); } diff --git a/packages/sw/src/scripts/operations.ts b/packages/sw/src/scripts/operations.ts index 02217ed74e..428f243ffe 100644 --- a/packages/sw/src/scripts/operations.ts +++ b/packages/sw/src/scripts/operations.ts @@ -3,63 +3,77 @@ * 各種操作 */ import * as Misskey from 'misskey-js'; -import { SwMessage, swMessageOrderType } from '@/types'; -import { acct as getAcct } from '@/filters/user'; +import type { SwMessage, SwMessageOrderType } from '@/types'; import { getAccountFromId } from '@/scripts/get-account-from-id'; import { getUrlWithLoginId } from '@/scripts/login-id'; -export const cli = new Misskey.api.APIClient({ origin, fetch: (...args) => fetch(...args) }); +export const cli = new Misskey.api.APIClient({ origin, fetch: (...args): Promise => fetch(...args) }); -export async function api(endpoint: E, userId: string, options?: Misskey.Endpoints[E]['req']) { - const account = await getAccountFromId(userId); - if (!account) return; +export async function api(endpoint: E, userId?: string, options?: O): Promise>> { + let account: { token: string; id: string } | void; - return cli.request(endpoint, options, account.token); + if (userId) { + account = await getAccountFromId(userId); + if (!account) return; + } + + return cli.request(endpoint, options, account?.token); +} + +// mark-all-as-read送出を1秒間隔に制限する +const readBlockingStatus = new Map(); +export function sendMarkAllAsRead(userId: string): Promise { + if (readBlockingStatus.get(userId)) return Promise.resolve(); + readBlockingStatus.set(userId, true); + return new Promise(resolve => { + setTimeout(() => { + readBlockingStatus.set(userId, false); + api('notifications/mark-all-as-read', userId).then(resolve, resolve); + }, 1000); + }); } // rendered acctからユーザーを開く -export function openUser(acct: string, loginId: string) { +export function openUser(acct: string, loginId?: string): ReturnType { return openClient('push', `/@${acct}`, loginId, { acct }); } // noteIdからノートを開く -export function openNote(noteId: string, loginId: string) { +export function openNote(noteId: string, loginId?: string): ReturnType { return openClient('push', `/notes/${noteId}`, loginId, { noteId }); } // noteIdからノートを開く -export function openAntenna(antennaId: string, loginId: string) { +export function openAntenna(antennaId: string, loginId: string): ReturnType { return openClient('push', `/timeline/antenna/${antennaId}`, loginId, { antennaId }); } // post-formのオプションから投稿フォームを開く -export async function openPost(options: any, loginId: string) { +export async function openPost(options: { initialText?: string; reply?: Misskey.entities.Note; renote?: Misskey.entities.Note }, loginId?: string): ReturnType { // クエリを作成しておく - let url = '/share?'; - if (options.initialText) url += `text=${options.initialText}&`; - if (options.reply) url += `replyId=${options.reply.id}&`; - if (options.renote) url += `renoteId=${options.renote.id}&`; + const url = '/share'; + const query = new URLSearchParams(); + if (options.initialText) query.set('text', options.initialText); + if (options.reply) query.set('replyId', options.reply.id); + if (options.renote) query.set('renoteId', options.renote.id); - return openClient('post', url, loginId, { options }); + return openClient('post', `${url}?${query}`, loginId, { options }); } -export async function openClient(order: swMessageOrderType, url: string, loginId: string, query: any = {}) { +export async function openClient(order: SwMessageOrderType, url: string, loginId?: string, query: Record = {}): Promise { const client = await findClient(); if (client) { - client.postMessage({ type: 'order', ...query, order, loginId, url } as SwMessage); + client.postMessage({ type: 'order', ...query, order, loginId, url } satisfies SwMessage); return client; } - return globalThis.clients.openWindow(getUrlWithLoginId(url, loginId)); + return globalThis.clients.openWindow(loginId ? getUrlWithLoginId(url, loginId) : url); } -export async function findClient() { +export async function findClient(): Promise { const clients = await globalThis.clients.matchAll({ type: 'window', }); - for (const c of clients) { - if (!new URL(c.url).searchParams.has('zen')) return c; - } - return null; + return clients.find(c => !(new URL(c.url)).searchParams.has('zen')) ?? null; } diff --git a/packages/sw/src/scripts/twemoji-base.ts b/packages/sw/src/scripts/twemoji-base.ts index 638aae3284..4b6a5353ea 100644 --- a/packages/sw/src/scripts/twemoji-base.ts +++ b/packages/sw/src/scripts/twemoji-base.ts @@ -1,12 +1,8 @@ -export const twemojiSvgBase = '/twemoji'; - export function char2fileName(char: string): string { - let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + let codes = Array.from(char) + .map(x => x.codePointAt(0)?.toString(16)) + .filter((x: T | undefined): x is T => x !== undefined); if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); - codes = codes.filter(x => x && x.length); + codes = codes.filter(x => x.length !== 0); return codes.join('-'); } - -export function char2filePath(char: string): string { - return `${twemojiSvgBase}/${char2fileName(char)}.svg`; -} diff --git a/packages/sw/src/scripts/url.ts b/packages/sw/src/scripts/url.ts deleted file mode 100644 index 5255076156..0000000000 --- a/packages/sw/src/scripts/url.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* objを検査して - * 1. 配列に何も入っていない時はクエリを付けない - * 2. プロパティがundefinedの時はクエリを付けない - * (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない) - */ -export function query(obj: object): string { - const params = Object.entries(obj) - .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) - .reduce((a, [k, v]) => (a[k] = v, a), {} as Record); - - return Object.entries(params) - .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) - .join('&'); -} - -export function appendQuery(url: string, query: string): string { - return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; -} diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index 9ceef890dd..9e0d9f0d1e 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -1,12 +1,12 @@ +import { get } from 'idb-keyval'; +import * as Acct from 'misskey-js/built/acct'; +import type { PushNotificationDataMap } from '@/types'; import { createEmptyNotification, createNotification } from '@/scripts/create-notification'; import { swLang } from '@/scripts/lang'; -import { api } from '@/scripts/operations'; -import { pushNotificationDataMap } from '@/types'; import * as swos from '@/scripts/operations'; -import { acct as getAcct } from '@/filters/user'; -globalThis.addEventListener('install', ev => { - //ev.waitUntil(globalThis.skipWaiting()); +globalThis.addEventListener('install', () => { + // ev.waitUntil(globalThis.skipWaiting()); }); globalThis.addEventListener('activate', ev => { @@ -43,8 +43,8 @@ globalThis.addEventListener('push', ev => { ev.waitUntil(globalThis.clients.matchAll({ includeUncontrolled: true, type: 'window', - }).then(async (clients: readonly WindowClient[]) => { - const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.data?.json(); + }).then(async () => { + const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json(); switch (data.type) { // case 'driveFileCreated': @@ -54,6 +54,10 @@ globalThis.addEventListener('push', ev => { if ((new Date()).getTime() - data.dateTime > 1000 * 60 * 60 * 24) break; return createNotification(data); + case 'readAllNotifications': + await globalThis.registration.getNotifications() + .then(notifications => notifications.forEach(n => n.close())); + break; } await createEmptyNotification(); @@ -62,13 +66,13 @@ globalThis.addEventListener('push', ev => { }); globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => { - ev.waitUntil((async () => { + ev.waitUntil((async (): Promise => { if (_DEV_) { console.log('notificationclick', ev.action, ev.notification.data); } const { action, notification } = ev; - const data: pushNotificationDataMap[keyof pushNotificationDataMap] = notification.data; + const data: PushNotificationDataMap[keyof PushNotificationDataMap] = notification.data ?? {}; const { userId: loginId } = data; let client: WindowClient | null = null; @@ -79,7 +83,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv if ('userId' in data.body) await swos.api('following/create', loginId, { userId: data.body.userId }); break; case 'showUser': - if ('user' in data.body) client = await swos.openUser(getAcct(data.body.user), loginId); + if ('user' in data.body) client = await swos.openUser(Acct.toString(data.body.user), loginId); break; case 'reply': if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, loginId); @@ -116,7 +120,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv if ('note' in data.body) { client = await swos.openNote(data.body.note.id, loginId); } else if ('user' in data.body) { - client = await swos.openUser(getAcct(data.body.user), loginId); + client = await swos.openUser(Acct.toString(data.body.user), loginId); } break; } @@ -124,13 +128,29 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv break; case 'unreadAntennaNote': client = await swos.openAntenna(data.body.antenna.id, loginId); + break; + default: + switch (action) { + case 'markAllAsRead': + await globalThis.registration.getNotifications() + .then(notifications => notifications.forEach(n => n.close())); + await get('accounts').then(accounts => { + return Promise.all(accounts.map(async account => { + await swos.sendMarkAllAsRead(account.id); + })); + }); + break; + case 'settings': + client = await swos.openClient('push', '/settings/notifications', loginId); + break; + } } if (client) { client.focus(); } if (data.type === 'notification') { - api('notifications/mark-all-as-read', data.userId); + await swos.sendMarkAllAsRead(loginId); } notification.close(); @@ -138,15 +158,18 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv }); globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => { - const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.notification.data; + const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data; - if (data.type === 'notification') { - api('notifications/mark-all-as-read', data.userId); - } + ev.waitUntil((async (): Promise => { + if (data.type === 'notification') { + await swos.sendMarkAllAsRead(data.userId); + } + return; + })()); }); globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => { - ev.waitUntil((async () => { + ev.waitUntil((async (): Promise => { switch (ev.data) { case 'clear': // Cache Storage全削除 diff --git a/packages/sw/src/types.ts b/packages/sw/src/types.ts index 176b181be0..cbcde7be36 100644 --- a/packages/sw/src/types.ts +++ b/packages/sw/src/types.ts @@ -1,46 +1,47 @@ -import * as Misskey from 'misskey-js'; +import type * as Misskey from 'misskey-js'; -export type swMessageOrderType = 'post' | 'push'; +export type SwMessageOrderType = 'post' | 'push'; export type SwMessage = { type: 'order'; - order: swMessageOrderType; - loginId: string; + order: SwMessageOrderType; + loginId?: string; url: string; - [x: string]: any; + [x: string]: unknown; }; // Defined also @/core/PushNotificationService.ts#L12 -type pushNotificationDataSourceMap = { +type PushNotificationDataSourceMap = { notification: Misskey.entities.Notification; unreadAntennaNote: { - antenna: { id: string, name: string }; + antenna: { id: string; name: string }; note: Misskey.entities.Note; }; + readAllNotifications: undefined; }; -export type pushNotificationData = { +export type PushNotificationData = { type: K; - body: pushNotificationDataSourceMap[K]; + body: PushNotificationDataSourceMap[K]; userId: string; dateTime: number; }; -export type pushNotificationDataMap = { - [K in keyof pushNotificationDataSourceMap]: pushNotificationData; +export type PushNotificationDataMap = { + [K in keyof PushNotificationDataSourceMap]: PushNotificationData; }; -export type badgeNames = - 'null' +export type BadgeNames = + | 'null' | 'antenna' | 'arrow-back-up' | 'at' | 'chart-arrows' | 'circle-check' + | 'medal' | 'messages' | 'plus' | 'quote' | 'repeat' | 'user-plus' - | 'users' - ; + | 'users'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff9765b5c4..14362d7fff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -763,6 +763,9 @@ importers: specifier: next version: 4.1.0(vue@3.2.47) devDependencies: + '@storybook/addon-actions': + specifier: 7.0.2 + version: 7.0.2(react-dom@18.2.0)(react@18.2.0) '@storybook/addon-essentials': specifier: 7.0.2 version: 7.0.2(react-dom@18.2.0)(react@18.2.0) @@ -1014,8 +1017,8 @@ importers: packages/sw: dependencies: esbuild: - specifier: 0.14.42 - version: 0.14.42 + specifier: 0.17.15 + version: 0.17.15 idb-keyval: specifier: 6.2.0 version: 6.2.0 @@ -2039,11 +2042,6 @@ packages: '@babel/highlight': 7.18.6 dev: true - /@babel/compat-data@7.20.10: - resolution: {integrity: sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==} - engines: {node: '>=6.9.0'} - dev: true - /@babel/compat-data@7.21.4: resolution: {integrity: sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g==} engines: {node: '>=6.9.0'} @@ -2056,13 +2054,13 @@ packages: '@ampproject/remapping': 2.2.0 '@babel/code-frame': 7.18.6 '@babel/generator': 7.21.3 - '@babel/helper-compilation-targets': 7.20.7(@babel/core@7.21.3) + '@babel/helper-compilation-targets': 7.21.4(@babel/core@7.21.3) '@babel/helper-module-transforms': 7.21.2 '@babel/helpers': 7.21.0 '@babel/parser': 7.21.3 '@babel/template': 7.20.7 '@babel/traverse': 7.21.3 - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 convert-source-map: 1.9.0 debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 @@ -2076,7 +2074,7 @@ packages: resolution: {integrity: sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 '@jridgewell/gen-mapping': 0.3.2 '@jridgewell/trace-mapping': 0.3.17 jsesc: 2.5.2 @@ -2097,20 +2095,6 @@ packages: '@babel/types': 7.21.4 dev: true - /@babel/helper-compilation-targets@7.20.7(@babel/core@7.21.3): - resolution: {integrity: sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/compat-data': 7.20.10 - '@babel/core': 7.21.3 - '@babel/helper-validator-option': 7.21.0 - browserslist: 4.21.5 - lru-cache: 5.1.1 - semver: 6.3.0 - dev: true - /@babel/helper-compilation-targets@7.21.4(@babel/core@7.21.3): resolution: {integrity: sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==} engines: {node: '>=6.9.0'} @@ -2347,7 +2331,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.18.6(@babel/core@7.21.3): resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==} @@ -3052,7 +3036,7 @@ packages: '@babel/helper-module-imports': 7.18.6 '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.21.3) - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 dev: true /@babel/plugin-transform-regenerator@7.20.5(@babel/core@7.21.3): @@ -3163,92 +3147,6 @@ packages: '@babel/helper-plugin-utils': 7.20.2 dev: true - /@babel/preset-env@7.20.2(@babel/core@7.21.3): - resolution: {integrity: sha512-1G0efQEWR1EHkKvKHqbG+IN/QdgwfByUpM5V5QroDzGV2t3S/WXNQd693cHiHTlCFMpr9B6FkPFXDA2lQcKoDg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/compat-data': 7.20.10 - '@babel/core': 7.21.3 - '@babel/helper-compilation-targets': 7.20.7(@babel/core@7.21.3) - '@babel/helper-plugin-utils': 7.20.2 - '@babel/helper-validator-option': 7.21.0 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-proposal-async-generator-functions': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-class-static-block': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-proposal-dynamic-import': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-export-namespace-from': 7.18.9(@babel/core@7.21.3) - '@babel/plugin-proposal-json-strings': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-logical-assignment-operators': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-proposal-optional-catch-binding': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-private-property-in-object': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.21.3) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.21.3) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.21.3) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-import-assertions': 7.20.0(@babel/core@7.21.3) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.21.3) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.21.3) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.21.3) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.21.3) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.21.3) - '@babel/plugin-transform-arrow-functions': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-transform-async-to-generator': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-transform-block-scoped-functions': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-block-scoping': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-transform-classes': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-transform-computed-properties': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-transform-destructuring': 7.21.3(@babel/core@7.21.3) - '@babel/plugin-transform-dotall-regex': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-duplicate-keys': 7.18.9(@babel/core@7.21.3) - '@babel/plugin-transform-exponentiation-operator': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-for-of': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-transform-function-name': 7.18.9(@babel/core@7.21.3) - '@babel/plugin-transform-literals': 7.18.9(@babel/core@7.21.3) - '@babel/plugin-transform-member-expression-literals': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-modules-amd': 7.20.11(@babel/core@7.21.3) - '@babel/plugin-transform-modules-commonjs': 7.21.2(@babel/core@7.21.3) - '@babel/plugin-transform-modules-systemjs': 7.20.11(@babel/core@7.21.3) - '@babel/plugin-transform-modules-umd': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-named-capturing-groups-regex': 7.20.5(@babel/core@7.21.3) - '@babel/plugin-transform-new-target': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-object-super': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-parameters': 7.21.3(@babel/core@7.21.3) - '@babel/plugin-transform-property-literals': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-regenerator': 7.20.5(@babel/core@7.21.3) - '@babel/plugin-transform-reserved-words': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-shorthand-properties': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-spread': 7.20.7(@babel/core@7.21.3) - '@babel/plugin-transform-sticky-regex': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-transform-template-literals': 7.18.9(@babel/core@7.21.3) - '@babel/plugin-transform-typeof-symbol': 7.18.9(@babel/core@7.21.3) - '@babel/plugin-transform-unicode-escapes': 7.18.10(@babel/core@7.21.3) - '@babel/plugin-transform-unicode-regex': 7.18.6(@babel/core@7.21.3) - '@babel/preset-modules': 0.1.5(@babel/core@7.21.3) - '@babel/types': 7.21.3 - babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@7.21.3) - babel-plugin-polyfill-corejs3: 0.6.0(@babel/core@7.21.3) - babel-plugin-polyfill-regenerator: 0.4.1(@babel/core@7.21.3) - core-js-compat: 3.29.1 - semver: 6.3.0 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/preset-env@7.21.4(@babel/core@7.21.3): resolution: {integrity: sha512-2W57zHs2yDLm6GD5ZpvNn71lZ0B/iypSdIeq25OurDKji6AdzV07qp4s3n1/x5BqtiGaTrPN3nerlSCaC5qNTw==} engines: {node: '>=6.9.0'} @@ -3432,14 +3330,6 @@ packages: - supports-color dev: true - /@babel/types@7.21.3: - resolution: {integrity: sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.19.4 - '@babel/helper-validator-identifier': 7.19.1 - to-fast-properties: 2.0.0 - /@babel/types@7.21.4: resolution: {integrity: sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==} engines: {node: '>=6.9.0'} @@ -3655,6 +3545,14 @@ packages: requiresBuild: true optional: true + /@esbuild/android-arm64@0.17.15: + resolution: {integrity: sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + /@esbuild/android-arm@0.17.14: resolution: {integrity: sha512-0CnlwnjDU8cks0yJLXfkaU/uoLyRf9VZJs4p1PskBr2AlAHeEsFEwJEo0of/Z3g+ilw5mpyDwThlxzNEIxOE4g==} engines: {node: '>=12'} @@ -3663,6 +3561,14 @@ packages: requiresBuild: true optional: true + /@esbuild/android-arm@0.17.15: + resolution: {integrity: sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + optional: true + /@esbuild/android-x64@0.17.14: resolution: {integrity: sha512-nrfQYWBfLGfSGLvRVlt6xi63B5IbfHm3tZCdu/82zuFPQ7zez4XjmRtF/wIRYbJQ/DsZrxJdEvYFE67avYXyng==} engines: {node: '>=12'} @@ -3671,6 +3577,14 @@ packages: requiresBuild: true optional: true + /@esbuild/android-x64@0.17.15: + resolution: {integrity: sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + optional: true + /@esbuild/darwin-arm64@0.17.14: resolution: {integrity: sha512-eoSjEuDsU1ROwgBH/c+fZzuSyJUVXQTOIN9xuLs9dE/9HbV/A5IqdXHU1p2OfIMwBwOYJ9SFVGGldxeRCUJFyw==} engines: {node: '>=12'} @@ -3679,6 +3593,14 @@ packages: requiresBuild: true optional: true + /@esbuild/darwin-arm64@0.17.15: + resolution: {integrity: sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + /@esbuild/darwin-x64@0.17.14: resolution: {integrity: sha512-zN0U8RWfrDttdFNkHqFYZtOH8hdi22z0pFm0aIJPsNC4QQZv7je8DWCX5iA4Zx6tRhS0CCc0XC2m7wKsbWEo5g==} engines: {node: '>=12'} @@ -3687,6 +3609,14 @@ packages: requiresBuild: true optional: true + /@esbuild/darwin-x64@0.17.15: + resolution: {integrity: sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + /@esbuild/freebsd-arm64@0.17.14: resolution: {integrity: sha512-z0VcD4ibeZWVQCW1O7szaLxGsx54gcCnajEJMdYoYjLiq4g1jrP2lMq6pk71dbS5+7op/L2Aod+erw+EUr28/A==} engines: {node: '>=12'} @@ -3695,6 +3625,14 @@ packages: requiresBuild: true optional: true + /@esbuild/freebsd-arm64@0.17.15: + resolution: {integrity: sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + optional: true + /@esbuild/freebsd-x64@0.17.14: resolution: {integrity: sha512-hd9mPcxfTgJlolrPlcXkQk9BMwNBvNBsVaUe5eNUqXut6weDQH8whcNaKNF2RO8NbpT6GY8rHOK2A9y++s+ehw==} engines: {node: '>=12'} @@ -3703,6 +3641,14 @@ packages: requiresBuild: true optional: true + /@esbuild/freebsd-x64@0.17.15: + resolution: {integrity: sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + /@esbuild/linux-arm64@0.17.14: resolution: {integrity: sha512-FhAMNYOq3Iblcj9i+K0l1Fp/MHt+zBeRu/Qkf0LtrcFu3T45jcwB6A1iMsemQ42vR3GBhjNZJZTaCe3VFPbn9g==} engines: {node: '>=12'} @@ -3711,6 +3657,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-arm64@0.17.15: + resolution: {integrity: sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-arm@0.17.14: resolution: {integrity: sha512-BNTl+wSJ1omsH8s3TkQmIIIQHwvwJrU9u1ggb9XU2KTVM4TmthRIVyxSp2qxROJHhZuW/r8fht46/QE8hU8Qvg==} engines: {node: '>=12'} @@ -3719,6 +3673,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-arm@0.17.15: + resolution: {integrity: sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-ia32@0.17.14: resolution: {integrity: sha512-91OK/lQ5y2v7AsmnFT+0EyxdPTNhov3y2CWMdizyMfxSxRqHazXdzgBKtlmkU2KYIc+9ZK3Vwp2KyXogEATYxQ==} engines: {node: '>=12'} @@ -3727,6 +3689,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-ia32@0.17.15: + resolution: {integrity: sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-loong64@0.17.14: resolution: {integrity: sha512-vp15H+5NR6hubNgMluqqKza85HcGJgq7t6rMH7O3Y6ApiOWPkvW2AJfNojUQimfTp6OUrACUXfR4hmpcENXoMQ==} engines: {node: '>=12'} @@ -3735,6 +3705,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-loong64@0.17.15: + resolution: {integrity: sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-mips64el@0.17.14: resolution: {integrity: sha512-90TOdFV7N+fgi6c2+GO9ochEkmm9kBAKnuD5e08GQMgMINOdOFHuYLPQ91RYVrnWwQ5683sJKuLi9l4SsbJ7Hg==} engines: {node: '>=12'} @@ -3743,6 +3721,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-mips64el@0.17.15: + resolution: {integrity: sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-ppc64@0.17.14: resolution: {integrity: sha512-NnBGeoqKkTugpBOBZZoktQQ1Yqb7aHKmHxsw43NddPB2YWLAlpb7THZIzsRsTr0Xw3nqiPxbA1H31ZMOG+VVPQ==} engines: {node: '>=12'} @@ -3751,6 +3737,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-ppc64@0.17.15: + resolution: {integrity: sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-riscv64@0.17.14: resolution: {integrity: sha512-0qdlKScLXA8MGVy21JUKvMzCYWovctuP8KKqhtE5A6IVPq4onxXhSuhwDd2g5sRCzNDlDjitc5sX31BzDoL5Fw==} engines: {node: '>=12'} @@ -3759,6 +3753,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-riscv64@0.17.15: + resolution: {integrity: sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-s390x@0.17.14: resolution: {integrity: sha512-Hdm2Jo1yaaOro4v3+6/zJk6ygCqIZuSDJHdHaf8nVH/tfOuoEX5Riv03Ka15LmQBYJObUTNS1UdyoMk0WUn9Ww==} engines: {node: '>=12'} @@ -3767,6 +3769,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-s390x@0.17.15: + resolution: {integrity: sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/linux-x64@0.17.14: resolution: {integrity: sha512-8KHF17OstlK4DuzeF/KmSgzrTWQrkWj5boluiiq7kvJCiQVzUrmSkaBvcLB2UgHpKENO2i6BthPkmUhNDaJsVw==} engines: {node: '>=12'} @@ -3775,6 +3785,14 @@ packages: requiresBuild: true optional: true + /@esbuild/linux-x64@0.17.15: + resolution: {integrity: sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + /@esbuild/netbsd-x64@0.17.14: resolution: {integrity: sha512-nVwpqvb3yyXztxIT2+VsxJhB5GCgzPdk1n0HHSnchRAcxqKO6ghXwHhJnr0j/B+5FSyEqSxF4q03rbA2fKXtUQ==} engines: {node: '>=12'} @@ -3783,6 +3801,14 @@ packages: requiresBuild: true optional: true + /@esbuild/netbsd-x64@0.17.15: + resolution: {integrity: sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + optional: true + /@esbuild/openbsd-x64@0.17.14: resolution: {integrity: sha512-1RZ7uQQ9zcy/GSAJL1xPdN7NDdOOtNEGiJalg/MOzeakZeTrgH/DoCkbq7TaPDiPhWqnDF+4bnydxRqQD7il6g==} engines: {node: '>=12'} @@ -3791,6 +3817,14 @@ packages: requiresBuild: true optional: true + /@esbuild/openbsd-x64@0.17.15: + resolution: {integrity: sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + optional: true + /@esbuild/sunos-x64@0.17.14: resolution: {integrity: sha512-nqMjDsFwv7vp7msrwWRysnM38Sd44PKmW8EzV01YzDBTcTWUpczQg6mGao9VLicXSgW/iookNK6AxeogNVNDZA==} engines: {node: '>=12'} @@ -3799,6 +3833,14 @@ packages: requiresBuild: true optional: true + /@esbuild/sunos-x64@0.17.15: + resolution: {integrity: sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + optional: true + /@esbuild/win32-arm64@0.17.14: resolution: {integrity: sha512-xrD0mccTKRBBIotrITV7WVQAwNJ5+1va6L0H9zN92v2yEdjfAN7864cUaZwJS7JPEs53bDTzKFbfqVlG2HhyKQ==} engines: {node: '>=12'} @@ -3807,6 +3849,14 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-arm64@0.17.15: + resolution: {integrity: sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + /@esbuild/win32-ia32@0.17.14: resolution: {integrity: sha512-nXpkz9bbJrLLyUTYtRotSS3t5b+FOuljg8LgLdINWFs3FfqZMtbnBCZFUmBzQPyxqU87F8Av+3Nco/M3hEcu1w==} engines: {node: '>=12'} @@ -3815,6 +3865,14 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-ia32@0.17.15: + resolution: {integrity: sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + optional: true + /@esbuild/win32-x64@0.17.14: resolution: {integrity: sha512-gPQmsi2DKTaEgG14hc3CHXHp62k8g6qr0Pas+I4lUxRMugGSATh/Bi8Dgusoz9IQ0IfdrvLpco6kujEIBoaogA==} engines: {node: '>=12'} @@ -3823,6 +3881,14 @@ packages: requiresBuild: true optional: true + /@esbuild/win32-x64@0.17.15: + resolution: {integrity: sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.37.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5284,10 +5350,10 @@ packages: '@storybook/node-logger': 7.0.2 '@types/ejs': 3.1.2 '@types/find-cache-dir': 3.2.1 - '@yarnpkg/esbuild-plugin-pnp': 3.0.0-rc.15(esbuild@0.17.14) + '@yarnpkg/esbuild-plugin-pnp': 3.0.0-rc.15(esbuild@0.17.15) browser-assert: 1.2.1 ejs: 3.1.8 - esbuild: 0.17.14 + esbuild: 0.17.15 esbuild-plugin-alias: 0.2.1 express: 4.18.2 find-cache-dir: 3.3.2 @@ -5342,17 +5408,6 @@ packages: - supports-color dev: true - /@storybook/channel-postmessage@7.0.0: - resolution: {integrity: sha512-Sy3oHL/xDRjUiHnM0ncnkbOE5pK3O72MjOoiLJX4FCI90w03KM4+F/N0eU2cXl6yXHuCyI5eJisEzQxTNsaJiw==} - dependencies: - '@storybook/channels': 7.0.0 - '@storybook/client-logger': 7.0.0 - '@storybook/core-events': 7.0.0 - '@storybook/global': 5.0.0 - qs: 6.11.1 - telejson: 7.0.4 - dev: true - /@storybook/channel-postmessage@7.0.2: resolution: {integrity: sha512-SZ/KqnZcx10W9hJbrzBKcP9dmgaeTaXugUhcgw1IkmjKWdsKazqFZCPwQWZZKAmhO4wYbyYOhkz3wfSIeB4mFw==} dependencies: @@ -5373,10 +5428,6 @@ packages: telejson: 7.0.4 dev: true - /@storybook/channels@7.0.0: - resolution: {integrity: sha512-adPIkvL4q37dGTWCpSzV8ETLdkxsg7BAgzeT9pustZJjRIZqAHGUAm7krDtGT7jbV4dU0Zw0VpUrnmyfxIkOKQ==} - dev: true - /@storybook/channels@7.0.2: resolution: {integrity: sha512-qkI8mFy9c8mxN2f01etayKhCaauL6RAsxRzbX1/pKj6UqhHWqqUbtHwymrv4hG5qDYjV1e9pd7ae5eNF8Kui0g==} dev: true @@ -5386,7 +5437,7 @@ packages: hasBin: true dependencies: '@babel/core': 7.21.3 - '@babel/preset-env': 7.20.2(@babel/core@7.21.3) + '@babel/preset-env': 7.21.4(@babel/core@7.21.3) '@ndelangen/get-tarball': 3.0.7 '@storybook/codemod': 7.0.2 '@storybook/core-common': 7.0.2 @@ -5410,7 +5461,7 @@ packages: get-port: 5.1.1 giget: 1.1.2 globby: 11.1.0 - jscodeshift: 0.14.0(@babel/preset-env@7.20.2) + jscodeshift: 0.14.0(@babel/preset-env@7.21.4) leven: 3.1.0 prettier: 2.8.7 prompts: 2.4.2 @@ -5430,12 +5481,6 @@ packages: - utf-8-validate dev: true - /@storybook/client-logger@7.0.0: - resolution: {integrity: sha512-wRZZiPta37DFc8SVZ8Q3ZqyTrs5qgO6bcCuVDRLQAcO0Oz4xKEVPEVfVVxSPZU/+p2ypqdBBCP2pdL/Jy86AJg==} - dependencies: - '@storybook/global': 5.0.0 - dev: true - /@storybook/client-logger@7.0.2: resolution: {integrity: sha512-rv7W2BhzIQHbFpUM5/CP/acS6T5lTmaxT0MbZ9n+9h++9QQU/cFOdkZgSUbLVAb1AeUGoLsk0HYzcqPpV35Xsw==} dependencies: @@ -5447,7 +5492,7 @@ packages: dependencies: '@babel/core': 7.21.3 '@babel/preset-env': 7.21.4(@babel/core@7.21.3) - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 '@storybook/csf': 0.1.0 '@storybook/csf-tools': 7.0.2 '@storybook/node-logger': 7.0.2 @@ -5495,8 +5540,8 @@ packages: '@types/node': 16.18.16 '@types/pretty-hrtime': 1.0.1 chalk: 4.1.2 - esbuild: 0.17.14 - esbuild-register: 3.4.2(esbuild@0.17.14) + esbuild: 0.17.15 + esbuild-register: 3.4.2(esbuild@0.17.15) file-system-cache: 2.0.2 find-up: 5.0.0 fs-extra: 11.1.0 @@ -5513,10 +5558,6 @@ packages: - supports-color dev: true - /@storybook/core-events@7.0.0: - resolution: {integrity: sha512-pxzNmgEI1p90bHyAYABHDDtB2XM5pffq6CqIHboK6aSCux7Cdc16IjOYq6BJIhCKaaI+qQHaFLR4JfaFAsxwQQ==} - dev: true - /@storybook/core-events@7.0.2: resolution: {integrity: sha512-1DCHCwHRL3+rlvnVVc/BCfReP31XaT2WYgcLeGTmkX1E43Po1MkgcM7PnJPSaa9POvSqZ+6YLZv5Bs1SXbufow==} dev: true @@ -5588,7 +5629,7 @@ packages: '@babel/generator': 7.21.3 '@babel/parser': 7.21.3 '@babel/traverse': 7.21.3 - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 '@storybook/csf': 0.1.0 '@storybook/types': 7.0.2 fs-extra: 11.1.0 @@ -5598,12 +5639,6 @@ packages: - supports-color dev: true - /@storybook/csf@0.0.2-next.11: - resolution: {integrity: sha512-xGt0YSVxZb43sKmEf1GIQD8xEbo+c+S6khDEL7Qu/pYA0gh5z3WUuhOlovnelYj/YJod+XRsfVvk23AaRfUJ4Q==} - dependencies: - type-fest: 2.19.0 - dev: true - /@storybook/csf@0.1.0: resolution: {integrity: sha512-uk+jMXCZ8t38jSTHk2o5btI+aV2Ksbvl6DoOv3r6VaCM1KZqeuMwtwywIQdflkA8/6q/dKT8z8L+g8hC4GC3VQ==} dependencies: @@ -5638,16 +5673,6 @@ packages: resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} dev: true - /@storybook/instrumenter@7.0.0: - resolution: {integrity: sha512-A7jBrV7VM3OxRgall8rpjagy3VC78A/OV1g1aYVVLpAF/+Odj+MeHHF179+fR6JBLnBgukNfsG7/ZHHGs0gL5Q==} - dependencies: - '@storybook/channels': 7.0.0 - '@storybook/client-logger': 7.0.0 - '@storybook/core-events': 7.0.0 - '@storybook/global': 5.0.0 - '@storybook/preview-api': 7.0.0 - dev: true - /@storybook/instrumenter@7.0.2: resolution: {integrity: sha512-zr9/fuaYtGVUtcL8XgjA4Iq5jtzdcqQyOSH4XLXtz6JtSad3lkRagbJo2Vzbw7dO/4vzjfTMxEzvWjUuPxLOhA==} dependencies: @@ -5662,7 +5687,7 @@ packages: resolution: {integrity: sha512-TmybnEXlv5Fu2/Hq4nRj7alS9mw4CasLR0RDwaAzS+Vpvu1TC4+j9rh+b1BHtmWebbJh0JMT6mgzPqOyJdgtQA==} dependencies: '@storybook/expect': 27.5.2-0 - '@storybook/instrumenter': 7.0.0 + '@storybook/instrumenter': 7.0.2 '@testing-library/jest-dom': 5.16.5 jest-mock: 27.5.1 dev: true @@ -5713,26 +5738,6 @@ packages: resolution: {integrity: sha512-Hhiu3+N3ZDcbrhOCBJTDJbn/mC4l0v3ziyAP3yalq/2ZR9R5kfsEHHakKmswsKKV+ey0gNGijFTy3soU5oSs+A==} dev: true - /@storybook/preview-api@7.0.0: - resolution: {integrity: sha512-Q0IYYH1gOmx42ClYlQfQPjuERBWM3Ey+3DFsLQaraKXDdgZ9wN7jPNuS7wxuUNylT0oa/3WjxT7qNfiGw8JtBw==} - dependencies: - '@storybook/channel-postmessage': 7.0.0 - '@storybook/channels': 7.0.0 - '@storybook/client-logger': 7.0.0 - '@storybook/core-events': 7.0.0 - '@storybook/csf': 0.0.2-next.11 - '@storybook/global': 5.0.0 - '@storybook/types': 7.0.0 - '@types/qs': 6.9.7 - dequal: 2.0.3 - lodash: 4.17.21 - memoizerific: 1.11.3 - qs: 6.11.1 - synchronous-promise: 2.0.17 - ts-dedent: 2.2.0 - util-deprecate: 1.0.2 - dev: true - /@storybook/preview-api@7.0.2: resolution: {integrity: sha512-QAlJM/r92+dQe/kB7MTTR9b/1mt9UJjxNjazGdEWipA/nw23kOF3o/hBcvKwBYkit4zGYsX70H+vuzW8hCo/lA==} dependencies: @@ -5902,15 +5907,6 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true - /@storybook/types@7.0.0: - resolution: {integrity: sha512-eCMW/xTVMswgD/58itibw8s8f2hZ7tciT3saRdGCymg9tPUhMC/9eGIIUSr/C+xfnCJEZm6J6DgEUo1xlifonw==} - dependencies: - '@storybook/channels': 7.0.0 - '@types/babel__core': 7.20.0 - '@types/express': 4.17.17 - file-system-cache: 2.0.2 - dev: true - /@storybook/types@7.0.2: resolution: {integrity: sha512-0OCt/kAexa8MCcljxA+yZxGMn0n2U2Ync0KxotItqNbKBKVkaLQUls0+IXTWSCpC/QJvNZ049jxUHHanNi/96w==} dependencies: @@ -6368,7 +6364,7 @@ packages: resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} dependencies: '@babel/parser': 7.21.3 - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 '@types/babel__generator': 7.6.4 '@types/babel__template': 7.4.1 '@types/babel__traverse': 7.18.3 @@ -6377,20 +6373,20 @@ packages: /@types/babel__generator@7.6.4: resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} dependencies: - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 dev: true /@types/babel__template@7.4.1: resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==} dependencies: '@babel/parser': 7.21.3 - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 dev: true /@types/babel__traverse@7.18.3: resolution: {integrity: sha512-1kbcJ40lLB7MHsj39U4Sh1uTd2E7rLEa79kmDpI6cy+XiXsteB3POdQomoq4FxszMrO3ZYchkhYJw7A2862b3w==} dependencies: - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 dev: true /@types/bcryptjs@2.4.2: @@ -7403,13 +7399,13 @@ packages: tunnel: 0.0.6 dev: true - /@yarnpkg/esbuild-plugin-pnp@3.0.0-rc.15(esbuild@0.17.14): + /@yarnpkg/esbuild-plugin-pnp@3.0.0-rc.15(esbuild@0.17.15): resolution: {integrity: sha512-kYzDJO5CA9sy+on/s2aIW0411AklfCi8Ck/4QDivOqsMKpStZA2SsR+X27VTggGwpStWaLrjJcDcdDMowtG8MA==} engines: {node: '>=14.15.0'} peerDependencies: esbuild: '>=0.10.0' dependencies: - esbuild: 0.17.14 + esbuild: 0.17.15 tslib: 2.5.0 dev: true @@ -10330,228 +10326,21 @@ packages: es6-symbol: 3.1.3 dev: false - /esbuild-android-64@0.14.42: - resolution: {integrity: sha512-P4Y36VUtRhK/zivqGVMqhptSrFILAGlYp0Z8r9UQqHJ3iWztRCNWnlBzD9HRx0DbueXikzOiwyOri+ojAFfW6A==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: false - optional: true - - /esbuild-android-arm64@0.14.42: - resolution: {integrity: sha512-0cOqCubq+RWScPqvtQdjXG3Czb3AWI2CaKw3HeXry2eoA2rrPr85HF7IpdU26UWdBXgPYtlTN1LUiuXbboROhg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: false - optional: true - - /esbuild-darwin-64@0.14.42: - resolution: {integrity: sha512-ipiBdCA3ZjYgRfRLdQwP82rTiv/YVMtW36hTvAN5ZKAIfxBOyPXY7Cejp3bMXWgzKD8B6O+zoMzh01GZsCuEIA==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /esbuild-darwin-arm64@0.14.42: - resolution: {integrity: sha512-bU2tHRqTPOaoH/4m0zYHbFWpiYDmaA0gt90/3BMEFaM0PqVK/a6MA2V/ypV5PO0v8QxN6gH5hBPY4YJ2lopXgA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /esbuild-freebsd-64@0.14.42: - resolution: {integrity: sha512-75h1+22Ivy07+QvxHyhVqOdekupiTZVLN1PMwCDonAqyXd8TVNJfIRFrdL8QmSJrOJJ5h8H1I9ETyl2L8LQDaw==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: false - optional: true - - /esbuild-freebsd-arm64@0.14.42: - resolution: {integrity: sha512-W6Jebeu5TTDQMJUJVarEzRU9LlKpNkPBbjqSu+GUPTHDCly5zZEQq9uHkmHHl7OKm+mQ2zFySN83nmfCeZCyNA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-32@0.14.42: - resolution: {integrity: sha512-Ooy/Bj+mJ1z4jlWcK5Dl6SlPlCgQB9zg1UrTCeY8XagvuWZ4qGPyYEWGkT94HUsRi2hKsXvcs6ThTOjBaJSMfg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-64@0.14.42: - resolution: {integrity: sha512-2L0HbzQfbTuemUWfVqNIjOfaTRt9zsvjnme6lnr7/MO9toz/MJ5tZhjqrG6uDWDxhsaHI2/nsDgrv8uEEN2eoA==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-arm64@0.14.42: - resolution: {integrity: sha512-c3Ug3e9JpVr8jAcfbhirtpBauLxzYPpycjWulD71CF6ZSY26tvzmXMJYooQ2YKqDY4e/fPu5K8bm7MiXMnyxuA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-arm@0.14.42: - resolution: {integrity: sha512-STq69yzCMhdRaWnh29UYrLSr/qaWMm/KqwaRF1pMEK7kDiagaXhSL1zQGXbYv94GuGY/zAwzK98+6idCMUOOCg==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-mips64le@0.14.42: - resolution: {integrity: sha512-QuvpHGbYlkyXWf2cGm51LBCHx6eUakjaSrRpUqhPwjh/uvNUYvLmz2LgPTTPwCqaKt0iwL+OGVL0tXA5aDbAbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-ppc64le@0.14.42: - resolution: {integrity: sha512-8ohIVIWDbDT+i7lCx44YCyIRrOW1MYlks9fxTo0ME2LS/fxxdoJBwHWzaDYhjvf8kNpA+MInZvyOEAGoVDrMHg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-riscv64@0.14.42: - resolution: {integrity: sha512-DzDqK3TuoXktPyG1Lwx7vhaF49Onv3eR61KwQyxYo4y5UKTpL3NmuarHSIaSVlTFDDpcIajCDwz5/uwKLLgKiQ==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-linux-s390x@0.14.42: - resolution: {integrity: sha512-YFRhPCxl8nb//Wn6SiS5pmtplBi4z9yC2gLrYoYI/tvwuB1jldir9r7JwAGy1Ck4D7sE7wBN9GFtUUX/DLdcEQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /esbuild-netbsd-64@0.14.42: - resolution: {integrity: sha512-QYSD2k+oT9dqB/4eEM9c+7KyNYsIPgzYOSrmfNGDIyJrbT1d+CFVKvnKahDKNJLfOYj8N4MgyFaU9/Ytc6w5Vw==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: false - optional: true - - /esbuild-openbsd-64@0.14.42: - resolution: {integrity: sha512-M2meNVIKWsm2HMY7+TU9AxM7ZVwI9havdsw6m/6EzdXysyCFFSoaTQ/Jg03izjCsK17FsVRHqRe26Llj6x0MNA==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: false - optional: true - /esbuild-plugin-alias@0.2.1: resolution: {integrity: sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==} dev: true - /esbuild-register@3.4.2(esbuild@0.17.14): + /esbuild-register@3.4.2(esbuild@0.17.15): resolution: {integrity: sha512-kG/XyTDyz6+YDuyfB9ZoSIOOmgyFCH+xPRtsCa8W85HLRV5Csp+o3jWVbOSHgSLfyLc5DmP+KFDNwty4mEjC+Q==} peerDependencies: esbuild: '>=0.12 <1' dependencies: debug: 4.3.4(supports-color@8.1.1) - esbuild: 0.17.14 + esbuild: 0.17.15 transitivePeerDependencies: - supports-color dev: true - /esbuild-sunos-64@0.14.42: - resolution: {integrity: sha512-uXV8TAZEw36DkgW8Ak3MpSJs1ofBb3Smkc/6pZ29sCAN1KzCAQzsje4sUwugf+FVicrHvlamCOlFZIXgct+iqQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: false - optional: true - - /esbuild-windows-32@0.14.42: - resolution: {integrity: sha512-4iw/8qWmRICWi9ZOnJJf9sYt6wmtp3hsN4TdI5NqgjfOkBVMxNdM9Vt3626G1Rda9ya2Q0hjQRD9W1o+m6Lz6g==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /esbuild-windows-64@0.14.42: - resolution: {integrity: sha512-j3cdK+Y3+a5H0wHKmLGTJcq0+/2mMBHPWkItR3vytp/aUGD/ua/t2BLdfBIzbNN9nLCRL9sywCRpOpFMx3CxzA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /esbuild-windows-arm64@0.14.42: - resolution: {integrity: sha512-+lRAARnF+hf8J0mN27ujO+VbhPbDqJ8rCcJKye4y7YZLV6C4n3pTRThAb388k/zqF5uM0lS5O201u0OqoWSicw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /esbuild@0.14.42: - resolution: {integrity: sha512-V0uPZotCEHokJdNqyozH6qsaQXqmZEOiZWrXnds/zaH/0SyrIayRXWRB98CENO73MIZ9T3HBIOsmds5twWtmgw==} - engines: {node: '>=12'} - requiresBuild: true - optionalDependencies: - esbuild-android-64: 0.14.42 - esbuild-android-arm64: 0.14.42 - esbuild-darwin-64: 0.14.42 - esbuild-darwin-arm64: 0.14.42 - esbuild-freebsd-64: 0.14.42 - esbuild-freebsd-arm64: 0.14.42 - esbuild-linux-32: 0.14.42 - esbuild-linux-64: 0.14.42 - esbuild-linux-arm: 0.14.42 - esbuild-linux-arm64: 0.14.42 - esbuild-linux-mips64le: 0.14.42 - esbuild-linux-ppc64le: 0.14.42 - esbuild-linux-riscv64: 0.14.42 - esbuild-linux-s390x: 0.14.42 - esbuild-netbsd-64: 0.14.42 - esbuild-openbsd-64: 0.14.42 - esbuild-sunos-64: 0.14.42 - esbuild-windows-32: 0.14.42 - esbuild-windows-64: 0.14.42 - esbuild-windows-arm64: 0.14.42 - dev: false - /esbuild@0.17.14: resolution: {integrity: sha512-vOO5XhmVj/1XQR9NQ1UPq6qvMYL7QFJU57J5fKBKBKxp17uDt5PgxFDb4A2nEiXhr1qQs4x0F5+66hVVw4ruNw==} engines: {node: '>=12'} @@ -10581,6 +10370,35 @@ packages: '@esbuild/win32-ia32': 0.17.14 '@esbuild/win32-x64': 0.17.14 + /esbuild@0.17.15: + resolution: {integrity: sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.15 + '@esbuild/android-arm64': 0.17.15 + '@esbuild/android-x64': 0.17.15 + '@esbuild/darwin-arm64': 0.17.15 + '@esbuild/darwin-x64': 0.17.15 + '@esbuild/freebsd-arm64': 0.17.15 + '@esbuild/freebsd-x64': 0.17.15 + '@esbuild/linux-arm': 0.17.15 + '@esbuild/linux-arm64': 0.17.15 + '@esbuild/linux-ia32': 0.17.15 + '@esbuild/linux-loong64': 0.17.15 + '@esbuild/linux-mips64el': 0.17.15 + '@esbuild/linux-ppc64': 0.17.15 + '@esbuild/linux-riscv64': 0.17.15 + '@esbuild/linux-s390x': 0.17.15 + '@esbuild/linux-x64': 0.17.15 + '@esbuild/netbsd-x64': 0.17.15 + '@esbuild/openbsd-x64': 0.17.15 + '@esbuild/sunos-x64': 0.17.15 + '@esbuild/win32-arm64': 0.17.15 + '@esbuild/win32-ia32': 0.17.15 + '@esbuild/win32-x64': 0.17.15 + /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -10847,7 +10665,7 @@ packages: engines: {node: '>=8.3.0'} dependencies: '@babel/traverse': 7.21.3 - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 c8: 7.13.0 transitivePeerDependencies: - supports-color @@ -13823,36 +13641,6 @@ packages: resolution: {integrity: sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==} engines: {node: '>=0.1.90'} - /jscodeshift@0.14.0(@babel/preset-env@7.20.2): - resolution: {integrity: sha512-7eCC1knD7bLUPuSCwXsMZUH51O8jIcoVyKtI6P0XM0IVzlGjckPy3FIwQlorzbN0Sg79oK+RlohN32Mqf/lrYA==} - hasBin: true - peerDependencies: - '@babel/preset-env': ^7.1.6 - dependencies: - '@babel/core': 7.21.3 - '@babel/parser': 7.21.3 - '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.21.3) - '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.21.3) - '@babel/plugin-transform-modules-commonjs': 7.21.2(@babel/core@7.21.3) - '@babel/preset-env': 7.20.2(@babel/core@7.21.3) - '@babel/preset-flow': 7.18.6(@babel/core@7.21.3) - '@babel/preset-typescript': 7.21.0(@babel/core@7.21.3) - '@babel/register': 7.21.0(@babel/core@7.21.3) - babel-core: 7.0.0-bridge.0(@babel/core@7.21.3) - chalk: 4.1.2 - flow-parser: 0.202.0 - graceful-fs: 4.2.11 - micromatch: 4.0.5 - neo-async: 2.6.2 - node-dir: 0.1.17 - recast: 0.21.5 - temp: 0.8.4 - write-file-atomic: 2.4.3 - transitivePeerDependencies: - - supports-color - dev: true - /jscodeshift@0.14.0(@babel/preset-env@7.21.4): resolution: {integrity: sha512-7eCC1knD7bLUPuSCwXsMZUH51O8jIcoVyKtI6P0XM0IVzlGjckPy3FIwQlorzbN0Sg79oK+RlohN32Mqf/lrYA==} hasBin: true @@ -19721,7 +19509,7 @@ packages: resolution: {integrity: sha512-jbOf7ByE3Zvtuk+429Jorl+eIeh2aB2Fx1GUo3xJd1aByJWE8KDlSEa6b11PB1ze8f0sRUBraRDinICCk0KY7g==} dependencies: '@babel/parser': 7.21.3 - '@babel/types': 7.21.3 + '@babel/types': 7.21.4 '@vue/compiler-dom': 3.2.47 '@vue/compiler-sfc': 3.2.47 ast-types: 0.14.2