Merge branch 'develop' into img-max

This commit is contained in:
tamaina 2023-04-12 02:09:36 +00:00
commit c9dd511d64
96 changed files with 1554 additions and 808 deletions

View File

@ -12,6 +12,52 @@
-->
## 13.x.x (unreleased)
### General
- カスタム絵文字関連の変更
* ートなどに含まれるemojispopulateEmojiの結果プロキシされたURLではなくオリジナルのURLを指すように
* MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように
### Client
-
### Server
- ノート作成時のアンテナ追加パフォーマンスを改善
- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io
## 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

View File

@ -991,6 +991,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:"

View File

@ -67,7 +67,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 +500,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 '<host>' or '<host>:<port>', 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 +918,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 +991,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:"
@ -1868,7 +1869,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"

View File

@ -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:

View File

@ -20,6 +20,7 @@ noNotes: "ノートはありません"
noNotifications: "通知はありません"
instance: "サーバー"
settings: "設定"
notificationSettings: "通知の設定"
basicSettings: "基本設定"
otherSettings: "その他の設定"
openInWindow: "ウィンドウで開く"
@ -920,8 +921,8 @@ subscribePushNotification: "プッシュ通知を有効化"
unsubscribePushNotification: "プッシュ通知を停止する"
pushNotificationAlreadySubscribed: "プッシュ通知は有効です"
pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応"
sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する"
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。"
sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する"
sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。"
windowMaximize: "最大化"
windowMinimize: "最小化"
windowRestore: "元に戻す"
@ -994,6 +995,7 @@ largeNoteReactions: "ノートのリアクションを大きく表示"
noteIdOrUrl: "ートIDまたはURL"
accountMigration: "アカウントの引っ越し"
accountMoved: "このユーザーは新しいアカウントに引っ越しました:"
forceShowAds: "常に広告を表示する"
_accountMigration:
moveTo: "このアカウントを新しいアカウントに引っ越す"
@ -1429,6 +1431,8 @@ _channel:
following: "フォロー中"
usersCount: "{n}人が参加中"
notesCount: "{n}投稿があります"
nameAndDescription: "名前と説明"
nameOnly: "名前のみ"
_menuDisplay:
sideFull: "横"

View File

@ -991,6 +991,7 @@ largeNoteReactions: "ノートのリアクションを大きする"
noteIdOrUrl: "ートIDかURL"
accountMigration: "アカウントのお引っ越し"
accountMoved: "このユーザーはさらのアカウントに引っ越したで:"
forceShowAds: "常に広告を表示しとく"
_accountMigration:
moveTo: "このアカウントをさらのアカウントに引っ越すで"
moveToLabel: "引っ越し先のアカウント:"

View File

@ -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: "ຊື່"

View File

@ -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: "เมื่อกำลังถูกกล่าวถึง"

View File

@ -991,6 +991,7 @@ largeNoteReactions: "使用大图标来显示回应"
noteIdOrUrl: "帖子ID或URL"
accountMigration: "账户迁移"
accountMoved: "此用户已迁移账户"
forceShowAds: "总是显示广告"
_accountMigration:
moveTo: "把这个账户迁移到新的账户"
moveToLabel: "迁移后的账户"

View File

@ -985,9 +985,15 @@ showClipButtonInNoteFooter: "將摘錄添加至貼文"
largeNoteReactions: "將貼文的反應放大顯示"
noteIdOrUrl: "貼文ID或URL"
accountMigration: "遷移帳戶"
forceShowAds: "總是顯示廣告"
_accountMigration:
moveTo: "將這個帳戶遷移至新的帳戶"
moveToLabel: "要遷移的帳戶:"
moveToLabel: "要遷移到的帳戶:"
moveAccountDescription: "這個操作不可撤銷。首先,請確認已在要遷移到的帳戶中為這個帳戶建立了一個別名。建立別名之後,像這樣輸入你要遷移到的帳戶:@person@instance.com"
moveFrom: "從其他帳戶遷移到這個帳戶"
moveFromLabel: "要遷移過來的帳戶:"
moveFromDescription: "如果你想把跟隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com"
migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外請確認在要遷移到的帳戶已經建立了一個別名。"
_achievements:
earnedAt: "獲得日期"
_types:

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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<void> {
@ -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(),
]);
}
}

View File

@ -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<void> {
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<void> {
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: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている

View File

@ -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);
}
}

View File

@ -43,12 +43,8 @@ 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()),
fromRedisConverter: (value) => {
// 原因不明だが配列以外が入ってくることがあるため
if (!Array.isArray(JSON.parse(value))) return undefined;
return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x]));
}, // TODO: Date型の変換
toRedisConverter: (value) => JSON.stringify(Array.from(value.values())),
fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
});
}
@ -271,16 +267,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なので??はだめ)
}
/**

View File

@ -26,8 +26,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 +37,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,
}));

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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, {});

View File

@ -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<T extends keyof PushNotificationsTypes>(type: T, body: Pus
@Injectable()
export class PushNotificationService {
private subscriptionsCache: RedisKVCache<SwSubscription[]>;
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<SwSubscription[]>(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: {

View File

@ -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<T>(config: Config, name: string, limitPerSec = -1) {
return new Bull<T>(name, {
@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue<DbJobData>;
export type DbQueue = Bull.Queue<DbJobData<keyof DbJobMap>>;
export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>;
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
@ -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,
],

View File

@ -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<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(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`);
});

View File

@ -64,8 +64,8 @@ export class RoleService implements OnApplicationShutdown {
public static NotAssignedError = class extends Error {};
constructor(
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -87,7 +87,7 @@ export class RoleService implements OnApplicationShutdown {
this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisForPubsub.on('message', this.onMessage);
this.redisForSub.on('message', this.onMessage);
}
@bindThis
@ -400,6 +400,6 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisForPubsub.off('message', this.onMessage);
this.redisForSub.off('message', this.onMessage);
}
}

View File

@ -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 => {

View File

@ -43,7 +43,7 @@ export class UserFollowingService implements OnModuleInit {
constructor(
private moduleRef: ModuleRef,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -79,7 +79,7 @@ export class UserFollowingService implements OnModuleInit {
}
@bindThis
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise<void> {
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
@ -139,7 +139,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 +155,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<void> {
if (follower.id === followee.id) return;
@ -233,7 +234,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 => {

View File

@ -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 } }]);
}
}
}

View File

@ -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);
}
}

View File

@ -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'),

View File

@ -8,7 +8,7 @@ export class RedisKVCache<T> {
private memoryCache: MemoryKVCache<T>;
private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
private fromRedisConverter: (value: string) => T;
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
lifetime: RedisKVCache<T>['lifetime'];
@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
private fromRedisConverter: (value: string) => T;
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime'];

View File

@ -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));

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> {
this.logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserJobData>, done: () => void): Promise<void> {
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });

View File

@ -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<DbUserImportToDbJobData>): Promise<void> {
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}`);
}
}
}

View File

@ -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<DbUserImportToDbJobData>): Promise<void> {
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}`);
}
}
}

View File

@ -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(),
});

View File

@ -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<RelationshipJobData>): Promise<string> {
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<RelationshipJobData>): Promise<string> {
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<RelationshipJobData>): Promise<string> {
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<RelationshipJobData>): Promise<string> {
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';
}
}

View File

@ -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<T extends keyof DbJobMap> = 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<string, unknown>;
export type ObjectStorageFileJobData = {

View File

@ -89,7 +89,7 @@ export class ApiServerService {
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
}>('/' + 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();

View File

@ -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';
@ -431,6 +432,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 };
@ -768,6 +770,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$channels_favorite,
$channels_unfavorite,
$channels_myFavorites,
$channels_search,
$charts_activeUsers,
$charts_apRequest,
$charts_drive,
@ -1099,6 +1102,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$channels_favorite,
$channels_unfavorite,
$channels_myFavorites,
$channels_search,
$charts_activeUsers,
$charts_apRequest,
$charts_drive,

View File

@ -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);
});

View File

@ -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';
@ -429,6 +430,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],

View File

@ -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<typeof meta, typeof paramDef> {
@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<typeof meta, typeof paramDef> {
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 })));
});
}
}

View File

@ -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<typeof meta, typeof paramDef> {
@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<typeof meta, typeof paramDef> {
@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);
}
}

View File

@ -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<typeof meta, typeof paramDef> {
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)));
});
}
}

View File

@ -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がミュートしているユーザーが関わるものだったら無視する

View File

@ -77,6 +77,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
createdAt: '2016-12-28T22:49:51.000Z',
description: 'I am a cool user!',
ffVisibility: 'public',
roles: [],
fields: [
{
name: 'Website',

View File

@ -398,6 +398,7 @@ function toStories(component: string): string {
Promise.all([
glob('src/components/global/*.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/pages/user/home.vue'),
])
.then((globs) => globs.flat())
.then((components) => Promise.all(components.map((component) => {

View File

@ -0,0 +1,31 @@
<template>
<MkPagination :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ i18n.ts.notFound }}</div>
</div>
</template>
<template #default="{ items }">
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
</template>
</MkPagination>
</template>
<script lang="ts" setup>
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
pagination: Paging;
noGap?: boolean;
extractor?: (item: any) => any;
}>(), {
extractor: (item) => item,
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -82,6 +82,7 @@ export default defineComponent({
omitted: null,
ignoreOmit: false,
defaultStore,
i18n,
};
},
mounted() {

View File

@ -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;

View File

@ -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<HTMLAudioElement | null>();
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;
});
</script>

View File

@ -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;
}
}
</style>

View File

@ -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;

View File

@ -5,7 +5,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getStaticImageUrl } from '@/scripts/media-proxy';
import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy';
import { defaultStore } from '@/store';
import { customEmojis } from '@/custom-emojis';
@ -15,25 +15,38 @@ const props = defineProps<{
noStyle?: boolean;
host?: string | null;
url?: string;
useOriginalSize?: boolean;
}>();
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substr(1, props.name.length - 2) : props.name).replace('@.', ''));
const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
const rawUrl = computed(() => {
if (props.url) {
return props.url;
}
if (props.host == null && !customEmojiName.value.includes('@')) {
if (isLocal.value) {
return customEmojis.value.find(x => x.name === customEmojiName.value)?.url ?? null;
}
return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
});
const url = computed(() =>
defaultStore.reactiveState.disableShowingAnimatedImages.value && rawUrl.value
? getStaticImageUrl(rawUrl.value)
: rawUrl.value,
);
const url = computed(() => {
if (rawUrl.value == null) return null;
const proxied =
(rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value))
? rawUrl.value
: getProxiedImageUrl(
rawUrl.value,
props.useOriginalSize ? undefined : 'emoji',
false,
true,
);
return defaultStore.reactiveState.disableShowingAnimatedImages.value
? getStaticImageUrl(proxied)
: proxied;
});
const alt = computed(() => `:${customEmojiName.value}:`);
let errored = $ref(url.value == null);

View File

@ -51,6 +51,10 @@ export default defineComponent({
type: Object,
default: null,
},
rootScale: {
type: Number,
default: 1,
}
},
render() {
@ -65,7 +69,12 @@ export default defineComponent({
const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm;
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => {
/**
* Gen Vue Elements from MFM AST
* @param ast MFM AST
* @param scale How times large the text is
*/
const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): VNode | string | (VNode | string)[] => {
switch (token.type) {
case 'text': {
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
@ -84,17 +93,17 @@ export default defineComponent({
}
case 'bold': {
return [h('b', genEl(token.children))];
return [h('b', genEl(token.children, scale))];
}
case 'strike': {
return [h('del', genEl(token.children))];
return [h('del', genEl(token.children, scale))];
}
case 'italic': {
return h('i', {
style: 'font-style: oblique;',
}, genEl(token.children));
}, genEl(token.children, scale));
}
case 'fn': {
@ -155,17 +164,17 @@ export default defineComponent({
case 'x2': {
return h('span', {
class: defaultStore.state.advancedMfm ? 'mfm-x2' : '',
}, genEl(token.children));
}, genEl(token.children, scale * 2));
}
case 'x3': {
return h('span', {
class: defaultStore.state.advancedMfm ? 'mfm-x3' : '',
}, genEl(token.children));
}, genEl(token.children, scale * 3));
}
case 'x4': {
return h('span', {
class: defaultStore.state.advancedMfm ? 'mfm-x4' : '',
}, genEl(token.children));
}, genEl(token.children, scale * 4));
}
case 'font': {
const family =
@ -182,7 +191,7 @@ export default defineComponent({
case 'blur': {
return h('span', {
class: '_mfm_blur_',
}, genEl(token.children));
}, genEl(token.children, scale));
}
case 'rainbow': {
const speed = validTime(token.props.args.speed) ?? '1s';
@ -191,9 +200,9 @@ export default defineComponent({
}
case 'sparkle': {
if (!useAnim) {
return genEl(token.children);
return genEl(token.children, scale);
}
return h(MkSparkle, {}, genEl(token.children));
return h(MkSparkle, {}, genEl(token.children, scale));
}
case 'rotate': {
const degrees = parseFloat(token.props.args.deg ?? '90');
@ -214,7 +223,8 @@ export default defineComponent({
}
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
style = `transform: scale(${x}, ${y});`;
style = `transform: scale(${x}, ${y});`;
scale = scale * Math.max(x, y);
break;
}
case 'fg': {
@ -231,24 +241,24 @@ export default defineComponent({
}
}
if (style == null) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
} else {
return h('span', {
style: 'display: inline-block; ' + style,
}, genEl(token.children));
}, genEl(token.children, scale));
}
}
case 'small': {
return [h('small', {
style: 'opacity: 0.7;',
}, genEl(token.children))];
}, genEl(token.children, scale))];
}
case 'center': {
return [h('div', {
style: 'text-align:center;',
}, genEl(token.children))];
}, genEl(token.children, scale))];
}
case 'url': {
@ -264,7 +274,7 @@ export default defineComponent({
key: Math.random(),
url: token.props.url,
rel: 'nofollow noopener',
}, genEl(token.children))];
}, genEl(token.children, scale))];
}
case 'mention': {
@ -303,11 +313,11 @@ export default defineComponent({
if (!this.nowrap) {
return [h('div', {
style: QUOTE_STYLE,
}, genEl(token.children))];
}, genEl(token.children, scale))];
} else {
return [h('span', {
style: QUOTE_STYLE,
}, genEl(token.children))];
}, genEl(token.children, scale))];
}
}
@ -319,6 +329,7 @@ export default defineComponent({
name: token.props.name,
normal: this.plain,
host: null,
useOriginalSize: scale >= 2.5,
})];
} else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -332,6 +343,7 @@ export default defineComponent({
url: this.emojiUrls ? this.emojiUrls[token.props.name] : null,
normal: this.plain,
host: this.author.host,
useOriginalSize: scale >= 2.5,
})];
}
}
@ -360,7 +372,7 @@ export default defineComponent({
}
case 'plain': {
return [h('span', genEl(token.children))];
return [h('span', genEl(token.children, scale))];
}
default: {
@ -373,6 +385,6 @@ export default defineComponent({
}).flat(Infinity) as (VNode | string)[];
// Parse ast to DOM
return h('span', genEl(ast));
return h('span', genEl(ast, this.rootScale));
},
});

View File

@ -2,6 +2,23 @@
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<div v-if="tab === 'search'">
<div class="_gaps">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkRadios v-model="searchType" @update:model-value="search()">
<option value="nameAndDescription">{{ i18n.ts._channel.nameAndDescription }}</option>
<option value="nameOnly">{{ i18n.ts._channel.nameOnly }}</option>
</MkRadios>
<MkButton large primary gradate rounded @click="search">{{ i18n.ts.search }}</MkButton>
</div>
<MkFoldableSection v-if="channelPagination">
<template #header>{{ i18n.ts.searchResult }}</template>
<MkChannelList :key="key" :pagination="channelPagination"/>
</MkFoldableSection>
</div>
<div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredPagination">
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
@ -28,17 +45,35 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import MkChannelPreview from '@/components/MkChannelPreview.vue';
import MkChannelList from '@/components/MkChannelList.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
const router = useRouter();
let tab = $ref('featured');
const props = defineProps<{
query: string;
type?: string;
}>();
let key = $ref('');
let tab = $ref('search');
let searchQuery = $ref('');
let searchType = $ref('nameAndDescription');
let channelPagination = $ref();
onMounted(() => {
searchQuery = props.query ?? '';
searchType = props.type ?? 'nameAndDescription';
});
const featuredPagination = {
endpoint: 'channels/featured' as const,
@ -58,6 +93,25 @@ const ownedPagination = {
limit: 10,
};
async function search() {
const query = searchQuery.toString().trim();
if (query == null || query === '') return;
const type = searchType.toString().trim();
channelPagination = {
endpoint: 'channels/search',
limit: 10,
params: {
query: searchQuery,
type: type,
},
};
key = query + type;
}
function create() {
router.push('/channels/new');
}
@ -69,6 +123,10 @@ const headerActions = $computed(() => [{
}]);
const headerTabs = $computed(() => [{
key: 'search',
title: i18n.ts.search,
icon: 'ti ti-search',
}, {
key: 'featured',
title: i18n.ts._channel.featured,
icon: 'ti ti-comet',

View File

@ -66,7 +66,7 @@ const recentPostsPagination = {
};
const popularPostsPagination = {
endpoint: 'gallery/featured' as const,
limit: 5,
noPaging: true,
};
const myPostsPagination = {
endpoint: 'i/gallery/posts' as const,

View File

@ -8,27 +8,29 @@
</div>
</template>
<template #default="{items}">
<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
<div class="body">
<div class="name">{{ token.name }}</div>
<div class="description">{{ token.description }}</div>
<MkKeyValue oneline>
<template #key>{{ i18n.ts.installedDate }}</template>
<template #value><MkTime :time="token.createdAt"/></template>
</MkKeyValue>
<MkKeyValue oneline>
<template #key>{{ i18n.ts.lastUsedDate }}</template>
<template #value><MkTime :time="token.lastUsedAt"/></template>
</MkKeyValue>
<details>
<summary>{{ i18n.ts.details }}</summary>
<ul>
<li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul>
</details>
<div class="actions">
<MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton>
<div class="_gaps">
<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
<div class="body">
<div class="name">{{ token.name }}</div>
<div class="description">{{ token.description }}</div>
<MkKeyValue oneline>
<template #key>{{ i18n.ts.installedDate }}</template>
<template #value><MkTime :time="token.createdAt"/></template>
</MkKeyValue>
<MkKeyValue oneline>
<template #key>{{ i18n.ts.lastUsedDate }}</template>
<template #value><MkTime :time="token.lastUsedAt"/></template>
</MkKeyValue>
<details>
<summary>{{ i18n.ts.details }}</summary>
<ul>
<li v-for="p in token.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul>
</details>
<div class="actions">
<MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton>
</div>
</div>
</div>
</div>
@ -51,6 +53,7 @@ const list = ref<any>(null);
const pagination = {
endpoint: 'i/apps' as const,
limit: 100,
noPaging: true,
params: {
sort: '+lastUsedAt',
},

View File

@ -61,6 +61,7 @@
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
</div>
<div>
<MkRadios v-model="emojiStyle">
@ -163,6 +164,7 @@ const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm'));
const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle'));
const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer'));
const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages'));
const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds'));
const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));

View File

@ -400,7 +400,7 @@ function menu(ev: MouseEvent, profileId: string) {
icon: 'ti ti-device-floppy',
action: () => save(profileId),
}, null, {
text: ts._preferencesBackups.delete,
text: ts.delete,
icon: 'ti ti-trash',
action: () => deleteProfile(profileId),
danger: true,

View File

@ -7,7 +7,7 @@
<FormSection>
<template #label>{{ i18n.ts.sounds }}</template>
<div class="_gaps_s">
<MkFolder v-for="type in Object.keys(sounds)" :key="type">
<MkFolder v-for="type in soundsKeys" :key="type">
<template #label>{{ i18n.t('_sfx.' + type) }}</template>
<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template>
@ -21,51 +21,44 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { Ref, computed, ref } from 'vue';
import XSound from './sounds.sound.vue';
import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue';
import { ColdDeviceStorage } from '@/store';
import { soundConfigStore } from '@/scripts/sound';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const masterVolume = computed({
get: () => {
return ColdDeviceStorage.get('sound_masterVolume');
},
set: (value) => {
ColdDeviceStorage.set('sound_masterVolume', value);
},
const masterVolume = computed(soundConfigStore.makeGetterSetter('sound_masterVolume'));
const soundsKeys = ['note', 'noteMy', 'notification', 'chat', 'chatBg', 'antenna', 'channel'] as const;
const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
note: soundConfigStore.reactiveState.sound_note,
noteMy: soundConfigStore.reactiveState.sound_noteMy,
notification: soundConfigStore.reactiveState.sound_notification,
chat: soundConfigStore.reactiveState.sound_chat,
chatBg: soundConfigStore.reactiveState.sound_chatBg,
antenna: soundConfigStore.reactiveState.sound_antenna,
channel: soundConfigStore.reactiveState.sound_channel,
});
const volumeIcon = computed(() => masterVolume.value === 0 ? 'ti ti-volume-3' : 'ti ti-volume');
const sounds = ref({
note: ColdDeviceStorage.get('sound_note'),
noteMy: ColdDeviceStorage.get('sound_noteMy'),
notification: ColdDeviceStorage.get('sound_notification'),
chat: ColdDeviceStorage.get('sound_chat'),
chatBg: ColdDeviceStorage.get('sound_chatBg'),
antenna: ColdDeviceStorage.get('sound_antenna'),
channel: ColdDeviceStorage.get('sound_channel'),
});
async function updated(type, sound) {
async function updated(type: keyof typeof sounds.value, sound) {
const v = {
type: sound.type,
volume: sound.volume,
};
ColdDeviceStorage.set('sound_' + type, v);
soundConfigStore.set(`sound_${type}`, v);
sounds.value[type] = v;
}
function reset() {
for (const sound of Object.keys(sounds.value)) {
const v = ColdDeviceStorage.default['sound_' + sound];
ColdDeviceStorage.set('sound_' + sound, v);
for (const sound of Object.keys(sounds.value) as Array<keyof typeof sounds.value>) {
const v = soundConfigStore.def[`sound_${sound}`].default;
soundConfigStore.set(`sound_${sound}`, v);
sounds.value[sound] = v;
}
}

View File

@ -7,18 +7,20 @@
<FormSection>
<MkPagination :pagination="pagination">
<template #default="{items}">
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_margin">
<template #icon>
<i v-if="webhook.active === false" class="ti ti-player-pause"></i>
<i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
</template>
{{ webhook.name || webhook.url }}
<template #suffix>
<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
</template>
</FormLink>
<div class="_gaps">
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`">
<template #icon>
<i v-if="webhook.active === false" class="ti ti-player-pause"></i>
<i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i>
<i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i>
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i>
</template>
{{ webhook.name || webhook.url }}
<template #suffix>
<MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime>
</template>
</FormLink>
</div>
</template>
</MkPagination>
</FormSection>
@ -35,7 +37,8 @@ import { i18n } from '@/i18n';
const pagination = {
endpoint: 'i/webhooks/list' as const,
limit: 10,
limit: 100,
noPaging: true,
};
const headerActions = $computed(() => []);

View File

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { userDetailed } from '../../../.storybook/fakes';
import { commonHandlers } from '../../../.storybook/mocks';
import home_ from './home.vue';
export const Default = {
render(args) {
return {
components: {
home_,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<home_ v-bind="props" />',
};
},
args: {
user: userDetailed(),
disableNotes: false,
},
parameters: {
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
rest.post('/api/users/notes', (req, res, ctx) => {
return res(ctx.json([]));
}),
rest.get('/api/charts/user/notes', (req, res, ctx) => {
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300);
return res(ctx.json({
total: Array.from({ length }, () => 0),
inc: Array.from({ length }, () => 0),
dec: Array.from({ length }, () => 0),
diffs: {
normal: Array.from({ length }, () => 0),
reply: Array.from({ length }, () => 0),
renote: Array.from({ length }, () => 0),
withFile: Array.from({ length }, () => 0),
},
}));
}),
rest.get('/api/charts/user/pv', (req, res, ctx) => {
const length = Math.max(Math.min(parseInt(req.url.searchParams.get('limit') ?? '30', 10), 1), 300);
return res(ctx.json({
upv: {
user: Array.from({ length }, () => 0),
visitor: Array.from({ length }, () => 0),
},
pv: {
user: Array.from({ length }, () => 0),
visitor: Array.from({ length }, () => 0),
},
}));
}),
],
},
chromatic: {
// `XActivity` is not compatible with Chromatic for now
disableSnapshot: true,
},
},
} satisfies StoryObj<typeof home_>;

View File

@ -2,7 +2,7 @@ import { query } from '@/scripts/url';
import { url } from '@/config';
import { instance } from '@/instance';
export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigin: boolean = false): string {
export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin: boolean = false, noFallback: boolean = false): string {
const localProxy = `${url}/proxy`;
if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
@ -15,7 +15,7 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigi
: 'image.webp'
}?${query({
url: imageUrl,
fallback: '1',
...(!noFallback ? { 'fallback': '1' } : {}),
...(type ? { [type]: '1' } : {}),
...(mustOrigin ? { origin: '1' } : {}),
})}`;

View File

@ -1,4 +1,56 @@
import { ColdDeviceStorage } from '@/store';
import { markRaw } from 'vue';
import { Storage } from '@/pizzax';
export const soundConfigStore = markRaw(new Storage('sound', {
mediaVolume: {
where: 'device',
default: 0.5
},
sound_masterVolume: {
where: 'device',
default: 0.3
},
sound_note: {
where: 'account',
default: { type: 'syuilo/n-aec', volume: 1 }
},
sound_noteMy: {
where: 'account',
default: { type: 'syuilo/n-cea-4va', volume: 1 }
},
sound_notification: {
where: 'account',
default: { type: 'syuilo/n-ea', volume: 1 }
},
sound_chat: {
where: 'account',
default: { type: 'syuilo/pope1', volume: 1 }
},
sound_chatBg: {
where: 'account',
default: { type: 'syuilo/waon', volume: 1 }
},
sound_antenna: {
where: 'account',
default: { type: 'syuilo/triple', volume: 1 }
},
sound_channel: {
where: 'account',
default: { type: 'syuilo/square-pico', volume: 1 }
},
}));
await soundConfigStore.ready;
//#region サウンドのColdDeviceStorage => indexedDBのマイグレーション
for (const target of Object.keys(soundConfigStore.state) as Array<keyof typeof soundConfigStore.state>) {
const value = localStorage.getItem(`miux:${target}`);
if (value) {
soundConfigStore.set(target, JSON.parse(value) as typeof soundConfigStore.def[typeof target]['default']);
localStorage.removeItem(`miux:${target}`);
}
}
//#endregion
const cache = new Map<string, HTMLAudioElement>();
@ -67,19 +119,20 @@ export function getAudio(file: string, useCache = true): HTMLAudioElement {
}
export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
const masterVolume = soundConfigStore.state.sound_masterVolume;
audio.volume = masterVolume - ((1 - volume) * masterVolume);
return audio;
}
export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification') {
const sound = ColdDeviceStorage.get(`sound_${type}`);
const sound = soundConfigStore.state[`sound_${type}`];
if (_DEV_) console.log('play', type, sound);
if (sound.type == null) return;
playFile(sound.type, sound.volume);
}
export function playFile(file: string, volume: number) {
const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
const masterVolume = soundConfigStore.state.sound_masterVolume;
if (masterVolume === 0) return;
const audio = setVolume(getAudio(file), volume);

View File

@ -298,6 +298,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
forceShowAds: {
where: 'device',
default: false,
},
aiChanMode: {
where: 'device',
default: false,
@ -343,15 +347,6 @@ export class ColdDeviceStorage {
darkTheme,
syncDeviceDarkMode: true,
plugins: [] as Plugin[],
mediaVolume: 0.5,
sound_masterVolume: 0.5,
sound_note: { type: 'syuilo/n-eca', volume: 0.5 },
sound_noteMy: { type: 'syuilo/n-cea-4va', volume: 0.5 },
sound_notification: { type: 'syuilo/n-ea', volume: 0.5 },
sound_chat: { type: 'syuilo/pope1', volume: 0.5 },
sound_chatBg: { type: 'syuilo/waon', volume: 0.5 },
sound_antenna: { type: 'syuilo/triple', volume: 0.5 },
sound_channel: { type: 'syuilo/square-pico', volume: 0.5 },
};
public static watchers: Watcher[] = [];

View File

@ -1,17 +1,18 @@
import { post } from '@/os';
import { api, post } from '@/os';
import { $i, login } from '@/account';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { mainRouter } from '@/router';
import { deepClone } from '@/scripts/clone';
export function swInject() {
navigator.serviceWorker.addEventListener('message', ev => {
navigator.serviceWorker.addEventListener('message', async ev => {
if (_DEV_) {
console.log('sw msg', ev.data);
}
if (ev.data.type !== 'order') return;
if (ev.data.loginId !== $i?.id) {
if (ev.data.loginId && ev.data.loginId !== $i?.id) {
return getAccountFromId(ev.data.loginId).then(account => {
if (!account) return;
return login(account.token, ev.data.url);
@ -19,8 +20,18 @@ export function swInject() {
}
switch (ev.data.order) {
case 'post':
return post(ev.data.options);
case 'post': {
const props = deepClone(ev.data.options);
// プッシュ通知から来たreply,renoteはtruncateBodyが通されているため、
// 完全なノートを取得しなおす
if (props.reply) {
props.reply = await api('notes/show', { noteId: props.reply.id });
}
if (props.renote) {
props.renote = await api('notes/show', { noteId: props.renote.id });
}
return post(props);
}
case 'push':
if (mainRouter.currentRoute.value.path === ev.data.url) {
return window.scroll({ top: 0, behavior: 'smooth' });

View File

@ -250,6 +250,7 @@ onMounted(() => {
> .widgets {
//--panelBorder: none;
width: 300px;
padding-bottom: calc(var(--margin) + env(safe-area-inset-bottom, 0px));
@media (max-width: $widgets-hide-threshold) {
display: none;
@ -304,7 +305,7 @@ onMounted(() => {
right: 0;
z-index: 1001;
height: 100dvh;
padding: var(--margin);
padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
overflow: auto;
background: var(--bg);

View File

@ -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;

View File

@ -3,7 +3,7 @@
<XWidgets :class="$style.widgets" :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button>
<button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
<button v-else class="_textButton mk-widget-edit" :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button>
</div>
</template>
@ -91,4 +91,8 @@ function updateWidgets(thisWidgets) {
.widgets {
width: 300px;
}
.edit {
width: 100%;
}
</style>

View File

@ -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,
},
};

View File

@ -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...');
}
})();

View File

@ -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:*"
},

View File

@ -1,3 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FIXME = any;
declare const _LANGS_: string[][];

View File

@ -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}` : '')}`;
};

View File

@ -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<void> => {
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<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]) {
export async function createNotification<K extends keyof PushNotificationDataMap>(data: PushNotificationDataMap[K]): Promise<void> {
const n = await composeNotification(data);
if (n) {
@ -36,9 +34,8 @@ export async function createNotification<K extends keyof pushNotificationDataMap
}
}
async function composeNotification(data: pushNotificationDataMap[keyof pushNotificationDataMap]): Promise<[string, NotificationOptions] | null> {
if (!swLang.i18n) swLang.fetchLocale();
const i18n = await swLang.i18n as I18n<any>;
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<void> {
return new Promise<void>(async res => {
if (!swLang.i18n) swLang.fetchLocale();
const i18n = await swLang.i18n as I18n<any>;
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();
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -1,4 +1,6 @@
export class I18n<T extends Record<string, any>> {
export type Locale = { [key: string]: string | Locale };
export class I18n<T extends Locale = Locale> {
public ts: T;
constructor(locale: T) {
@ -13,7 +15,8 @@ export class I18n<T extends Record<string, any>> {
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
let str = key.split('.').reduce<Locale | Locale[keyof Locale]>((o, i) => o[i], this.ts);
if (typeof str !== 'string') throw new Error();
if (args) {
for (const [k, v] of Object.entries(args)) {

View File

@ -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<I18n<Locale>> {
this.lang = Promise.resolve(newLang);
set('lang', newLang);
return this.fetchLocale();
}
public i18n: Promise<I18n<any>> | null = null;
public i18n: Promise<I18n> | null = null;
public fetchLocale() {
return this.i18n = this._fetch();
public fetchLocale(): Promise<I18n<Locale>> {
return (this.i18n = this._fetch());
}
private async _fetch() {
private async _fetch(): Promise<I18n<Locale>> {
// 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<Locale>(await localeRes.json());
}
}

View File

@ -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();
}

View File

@ -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<Response> => fetch(...args) });
export async function api<E extends keyof Misskey.Endpoints>(endpoint: E, userId: string, options?: Misskey.Endpoints[E]['req']) {
const account = await getAccountFromId(userId);
if (!account) return;
export async function api<E extends keyof Misskey.Endpoints, O extends Misskey.Endpoints[E]['req']>(endpoint: E, userId?: string, options?: O): Promise<void | ReturnType<typeof cli.request<E, O>>> {
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<string, boolean>();
export function sendMarkAllAsRead(userId: string): Promise<null | undefined | void> {
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<typeof openClient> {
return openClient('push', `/@${acct}`, loginId, { acct });
}
// noteIdからートを開く
export function openNote(noteId: string, loginId: string) {
export function openNote(noteId: string, loginId?: string): ReturnType<typeof openClient> {
return openClient('push', `/notes/${noteId}`, loginId, { noteId });
}
// noteIdからートを開く
export function openAntenna(antennaId: string, loginId: string) {
export function openAntenna(antennaId: string, loginId: string): ReturnType<typeof openClient> {
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<typeof openClient> {
// クエリを作成しておく
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<string, SwMessage[string]> = {}): Promise<WindowClient | null> {
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<WindowClient | null> {
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;
}

View File

@ -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(<T>(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`;
}

View File

@ -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<string, any>);
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}`;
}

View File

@ -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<void> => {
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<void> => {
if (data.type === 'notification') {
await swos.sendMarkAllAsRead(data.userId);
}
return;
})());
});
globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {
ev.waitUntil((async () => {
ev.waitUntil((async (): Promise<void> => {
switch (ev.data) {
case 'clear':
// Cache Storage全削除

View File

@ -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<K extends keyof pushNotificationDataSourceMap> = {
export type PushNotificationData<K extends keyof PushNotificationDataSourceMap> = {
type: K;
body: pushNotificationDataSourceMap[K];
body: PushNotificationDataSourceMap[K];
userId: string;
dateTime: number;
};
export type pushNotificationDataMap = {
[K in keyof pushNotificationDataSourceMap]: pushNotificationData<K>;
export type PushNotificationDataMap = {
[K in keyof PushNotificationDataSourceMap]: PushNotificationData<K>;
};
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';

View File

@ -1014,8 +1014,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
@ -3655,6 +3655,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 +3671,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 +3687,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 +3703,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 +3719,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 +3735,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 +3751,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 +3767,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 +3783,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 +3799,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 +3815,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 +3831,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 +3847,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 +3863,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 +3879,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 +3895,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 +3911,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 +3927,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 +3943,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 +3959,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 +3975,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 +3991,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 +5460,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
@ -5495,8 +5671,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
@ -7403,13 +7579,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 +10506,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 +10550,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'}