mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-27 04:53:08 +02:00
Merge branch 'develop' into mkjs-n
This commit is contained in:
commit
d647df7f63
116 changed files with 2386 additions and 1892 deletions
|
@ -38,6 +38,8 @@
|
|||
- fix:ロールタイムラインにて全ての投稿が流れてしまう問題の修正
|
||||
|
||||
### Server
|
||||
- bullをbull-mqにアップグレードし、ジョブキューのパフォーマンスを改善
|
||||
- ストリーミングのパフォーマンスを改善
|
||||
- Fix: お知らせの画像URLを空にできない問題を修正
|
||||
|
||||
## 13.12.2
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
_lang_: "العربية"
|
||||
headlineMisskey: "شبكة مرتبطة بالملاحظات"
|
||||
introMisskey: "اهلا بك! ميسكي هو منصة تدوين مصغر لا مركزية ومفتوحة المصدر.\nيمكنك مشاركة \"ملاحظات\" عن ما يجري حولك، وإخبار الجميع عن نفسك 📡\nتسمح لك \"الانفعالات\" بتعبير عن شعورك حول ملاحظات الآخرين 👍\nاكتشف عالمًا جديدًا 🚀"
|
||||
poweredByMisskeyDescription: "{name} هو إحدى الخِدمات التي تستخدم المنصة مفتوحة المصدر <b>ميسكي</b> (يشار إليه كمثيل ميسكي)"
|
||||
monthAndDay: "{day}/{month}"
|
||||
search: "البحث"
|
||||
notifications: "الإشعارات"
|
||||
|
@ -49,6 +50,7 @@ deleteAndEdit: "إزالة وإعادة الصياغة"
|
|||
deleteAndEditConfirm: "أمتأكد من حذف الملاحظة؟ ستفقد كل مشاركاتها، والتفاعلات، والردود عليها."
|
||||
addToList: "أضفه إلى قائمة"
|
||||
sendMessage: "أرسل رسالة"
|
||||
copyRSS: "انسخ رابط RSS"
|
||||
copyUsername: "انسخ اسم المستخدم"
|
||||
searchUser: "ابحث عن مستخدمين"
|
||||
reply: "رد"
|
||||
|
@ -102,6 +104,8 @@ renoted: "أُعيد نشره"
|
|||
cantRenote: "لا يمكن إعادة نشر الملاحظة"
|
||||
cantReRenote: "لا يمكنك إعادة نشر ملاحظة معاد نشرها"
|
||||
quote: "اقتبس"
|
||||
inChannelRenote: "إعادة نشر في قناة"
|
||||
inChannelQuote: "اقتباس في قناة"
|
||||
pinnedNote: "ملاحظة مدبسة"
|
||||
pinned: "دبّسها على الصفحة الشخصية"
|
||||
you: "أنت"
|
||||
|
@ -119,6 +123,8 @@ unmarkAsSensitive: "ألغ تعيينه كمحتوى حساس"
|
|||
enterFileName: "ادخل اسم الملف"
|
||||
mute: "اكتم"
|
||||
unmute: "إلغاء الكتم"
|
||||
renoteMute: "اكتم إعادة النشر"
|
||||
renoteUnmute: "ارفع الكتم عن إعادة النشر"
|
||||
block: "احجب"
|
||||
unblock: "إلغاء الحجب"
|
||||
suspend: "علِق"
|
||||
|
@ -141,6 +147,7 @@ emojiUrl: "رابط الإيموجي"
|
|||
addEmoji: "إضافة إيموجي"
|
||||
settingGuide: "الإعدادات المستحسنة"
|
||||
cacheRemoteFiles: "خزن مؤقتا الملفات البعيدة"
|
||||
cacheRemoteFilesDescription: "إذا عُطل هذا الإعداد، ستُحمل الملفات من المثيل البعيد، هذا سيقلل من المساحة المستغلة على القرص لكن سيزيد حجم تدفق البيانات وهذا لأن الصور المصغرة لن تولّد."
|
||||
flagAsBot: "علّمه كحساب آلي"
|
||||
flagAsBotDescription: "فعّل هذا الخيار إذا كان هذا الحساب يُدار عبر برمجية. إذا فُعل فسيكون بمثابة علامة للمطورين الآخرين لتجنب سلاسل لا متناهية من التفاعل بين حسابات الآلية وضبط أنظمة ميسكي للتعامل مع هذا الحساب كآلي."
|
||||
flagAsCat: "علّم هذا الحساب كحساب قط"
|
||||
|
@ -253,6 +260,7 @@ startMessaging: "ابدأ محادثة"
|
|||
nUsersRead: "قرأه {n}"
|
||||
agreeTo: "اوافق على {0}"
|
||||
agree: "أقبل"
|
||||
agreeBelow: "أقبل ما يلي"
|
||||
basicNotesBeforeCreateAccount: "ملاحظات مهمة"
|
||||
termsOfService: "شروط الخدمة"
|
||||
start: "البداية"
|
||||
|
@ -362,6 +370,7 @@ antennaExcludeKeywords: "الكلمات المفتاحية المستثناة"
|
|||
antennaKeywordsDescription: "افصل بينهم بمسافة لاستخدام معامل \"و\" أو بسطر لاستخدام معامل \"أو\""
|
||||
notifyAntenna: "نبهني بصول ملاحظات جديدة"
|
||||
withFileAntenna: "ملاحظات تحوي ملفات فقط"
|
||||
enableServiceworker: "فعّل إرسال الإشعارات للمتصفح"
|
||||
antennaUsersDescription: "اكتب اسم مستخدم لكل سطر"
|
||||
caseSensitive: "حساسية حالة الأحرف"
|
||||
withReplies: "بالردود"
|
||||
|
@ -391,6 +400,7 @@ moderation: "الإشراف"
|
|||
nUsersMentioned: "{n} مستخدمين أُشير إليهم"
|
||||
securityKey: "مفتاح الأمان"
|
||||
lastUsed: "آخر استخدام"
|
||||
lastUsedAt: "آخر استخدام: {t}"
|
||||
unregister: "إلغاء التسجيل"
|
||||
passwordLessLogin: "لِج مِن دون كلمة سرية"
|
||||
resetPassword: "أعد تعيين كلمتك السرية"
|
||||
|
@ -441,6 +451,7 @@ or: "أو"
|
|||
language: "اللغة"
|
||||
uiLanguage: "لغة واجهة المستخدم"
|
||||
aboutX: "عن {x}"
|
||||
emojiStyle: "نمط الوجوه التعبيرية"
|
||||
noHistory: "السجل فارغ"
|
||||
signinHistory: "تاريخ تسجيل الدخول"
|
||||
doing: "انتظر لحظة"
|
||||
|
@ -451,6 +462,7 @@ createAccount: "أنشئ حسابًا"
|
|||
existingAccount: "الحسابات الموجودة"
|
||||
regenerate: "أعِد التوليد"
|
||||
fontSize: "حجم الخط"
|
||||
limitTo: "سقفهُ لـ{x}"
|
||||
noFollowRequests: "ليس لديك طلبات متابعة معلقة"
|
||||
openImageInNewTab: "إفتح الصورة بصفحة جديدة"
|
||||
dashboard: "لوحة التحكم"
|
||||
|
@ -479,6 +491,7 @@ objectStorageUseProxyDesc: "عطل هذا الخيار إذا لم ترد است
|
|||
serverLogs: "سجلات الخادم"
|
||||
deleteAll: "حذف الكل"
|
||||
showFixedPostForm: "أظهر نموذج الكتابة في أعلى الصفحة"
|
||||
showFixedPostFormInChannel: "أظهر نموذج الكتابة في أعلى الخط الزمني (قنوات)"
|
||||
newNoteRecived: "هناك ملاحظات جديدة"
|
||||
sounds: "الرنات"
|
||||
sound: "الرنات"
|
||||
|
@ -514,6 +527,7 @@ userSilenced: "كُتم هذا المستخدم."
|
|||
yourAccountSuspendedTitle: "هذا الحساب معلق"
|
||||
yourAccountSuspendedDescription: "عُلق الحساب بسبب انتهاك شروط خدمة المثيل و ما شابه. إذا أردت معرفة التفصيل تواصل مع مدير المثيل. رجاءً لا تنشئ حساب جديد."
|
||||
accountDeleted: "حُذف الحساب"
|
||||
accountDeletedDescription: "حُذف هذا الحساب."
|
||||
menu: "القائمة"
|
||||
divider: "فاصل"
|
||||
addItem: "إضافة عنصر"
|
||||
|
@ -539,6 +553,7 @@ author: "الكاتب"
|
|||
leaveConfirm: "لديك تغييرات غير محفوظة. أتريد المتابعة دون حفظها؟"
|
||||
manage: "إدارة "
|
||||
plugins: "الإضافات"
|
||||
preferencesBackups: "النُسخ الاحتياطية للإعدادات"
|
||||
useFullReactionPicker: "استخدم الحجم الكامل لمنتقي التفاعلات"
|
||||
width: "العرض"
|
||||
height: "الإرتفاع"
|
||||
|
@ -648,6 +663,7 @@ contact: "التواصل"
|
|||
useSystemFont: "استخدم الخط الافتراضية للنظام"
|
||||
clips: "مشابك"
|
||||
experimentalFeatures: "ميّزات اختبارية"
|
||||
experimental: "اختباري"
|
||||
developer: "المطور"
|
||||
makeExplorable: "أظهر الحساب في صفحة \"استكشاف\""
|
||||
makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\""
|
||||
|
@ -669,6 +685,7 @@ accentColor: "طابع لوني"
|
|||
textColor: "لون النص"
|
||||
saveAs: "احفظ كـ..."
|
||||
advanced: "متقدم"
|
||||
advancedSettings: "إعدادات متقدمة"
|
||||
value: "القيمة"
|
||||
createdAt: "أُنشئ في"
|
||||
updatedAt: "حُدّث في"
|
||||
|
@ -733,6 +750,7 @@ popularPosts: "المشاركات المتداولة"
|
|||
shareWithNote: "شاركه في ملاحظة"
|
||||
ads: "الإعلانات"
|
||||
expiration: "ينتهي استطلاع الرأي في"
|
||||
startingperiod: "ابدأ"
|
||||
memo: "تذكير"
|
||||
priority: "الأولوية"
|
||||
high: "عالية"
|
||||
|
@ -763,6 +781,7 @@ lastCommunication: "آخر تواصل"
|
|||
resolved: "عولج"
|
||||
unresolved: "لم يعالج"
|
||||
breakFollow: "إلغاء الاشتراك"
|
||||
breakFollowConfirm: "أمتأكد من إزالة المتابِع ؟"
|
||||
itsOn: "مفعّل"
|
||||
itsOff: "معطّل"
|
||||
emailRequiredForSignup: "عنوان البريد الإلكتروني إلزامي للتسجيل"
|
||||
|
@ -777,6 +796,7 @@ muteThread: "اكتم النقاش"
|
|||
unmuteThread: "ارفع الكتم عن النقاش"
|
||||
ffVisibility: "مرئية المتابِعين/المتابَعين"
|
||||
ffVisibilityDescription: "يسمح لك بتحديد من يمكنهم رؤية متابِعيك ومتابَعيك."
|
||||
continueThread: "اعرض بقية النقاش"
|
||||
deleteAccountConfirm: "سيحذف حسابك نهائيًا، أتريد المتابعة؟"
|
||||
incorrectPassword: "كلمة السر خاطئة."
|
||||
voteConfirm: "متيقِّن من تصويتك لـ {choice}؟"
|
||||
|
@ -798,25 +818,127 @@ tenMinutes: "10 دقائق"
|
|||
oneHour: "ساعة"
|
||||
oneDay: "يوم"
|
||||
oneWeek: "أسبوع"
|
||||
oneMonth: "شهر"
|
||||
failedToFetchAccountInformation: "تعذر جلب معلومات الحساب"
|
||||
cropNo: "استخدمها كما هي"
|
||||
file: "الملفات"
|
||||
recentNHours: "آخر {n} ساعة"
|
||||
recentNDays: "آخر {n} أيام"
|
||||
noEmailServerWarning: "خادم البريد غير مضبوط."
|
||||
thereIsUnresolvedAbuseReportWarning: "توجد بلاغات غير معالجة."
|
||||
recommended: "مقترح"
|
||||
driveCapOverrideLabel: "غيّر حجم قرص التخزين لهذا المستخدم"
|
||||
driveCapOverrideCaption: "أعد الحجم إلى القيمة الافتراضية بإدخال 0 أو أقل."
|
||||
requireAdminForView: "لاستعراض هذه الصفحة وجب عليك الولوج كمدير."
|
||||
typeToConfirm: "أدخل {x} للتأكيد"
|
||||
deleteAccount: "احذف الحساب"
|
||||
document: "التوثيق"
|
||||
numberOfPageCache: "عدد الصفحات المخزنة مؤقتًا"
|
||||
logoutConfirm: "أتريد الخروج؟"
|
||||
lastActiveDate: "آخر استخدام"
|
||||
statusbar: "شريط الحالة"
|
||||
pleaseSelect: "حدد خيارًا"
|
||||
reverse: "اقلب"
|
||||
colored: "ملوّن"
|
||||
label: "التسمية"
|
||||
type: "نوع"
|
||||
speed: "سرعة"
|
||||
slow: "بطيء"
|
||||
fast: "سريع"
|
||||
sensitiveMediaDetection: "التعرف على المحتوى الحساس"
|
||||
localOnly: "المحلي فقط"
|
||||
failedToUpload: "فشل الرفع"
|
||||
cannotUploadBecauseInappropriate: "تعذر رفع الملف لوجود محتوى حساس فيه."
|
||||
cannotUploadBecauseNoFreeSpace: "تعذر رفع الملف لنقص مساحة التخزين."
|
||||
cannotUploadBecauseExceedsFileSizeLimit: "تعذر رفع الملف بسبب تجاوز حجمه للحد المسموح"
|
||||
beta: "بيتا"
|
||||
navbar: "شريط التنقل"
|
||||
shuffle: "خلط"
|
||||
account: "الحسابات"
|
||||
move: "أنقل"
|
||||
pushNotification: "إرسال الإشعارات"
|
||||
subscribePushNotification: "فعّل إرسال الإشعارات"
|
||||
unsubscribePushNotification: "عطل إرسال الإشعارات"
|
||||
pushNotificationAlreadySubscribed: "إرسال الإشعارات مفعل سلفًا"
|
||||
pushNotificationNotSupported: "متصفحك لا يدعم إرسال الإشعارات أو المثيل لا يدعمها."
|
||||
sendPushNotificationReadMessage: "احذف الإشعارات فور قراءتها"
|
||||
sendPushNotificationReadMessageCaption: "هذا قد يزيد من معدل استهلاك الطاقة لجهازك."
|
||||
caption: "التعليق التوضيحي"
|
||||
tools: "أدوات"
|
||||
cannotLoad: "تعذر التحميل"
|
||||
like: "أعجبني"
|
||||
unlike: "ألغِ الإعجاب"
|
||||
show: "المظهر"
|
||||
neverShow: "لا تظهره مجددًا"
|
||||
didYouLikeMisskey: "هل أعجبك ميسكي؟"
|
||||
roles: "الأدوار"
|
||||
role: "الدور"
|
||||
noRole: "لم يُعثر على دور"
|
||||
normalUser: "مستخدم عادي"
|
||||
undefined: "غير معرّف"
|
||||
color: "اللون"
|
||||
manageCustomEmojis: "إدارة الإيموجي المخصصة"
|
||||
cannotPerformTemporary: "غير متاح مؤقتاً"
|
||||
permissionDeniedError: "رُفضة العملية"
|
||||
preset: "إعدادات مسبقة"
|
||||
selectFromPresets: "اختر من الإعدادات المسبقة"
|
||||
achievements: "الإنجازات"
|
||||
gotInvalidResponseError: "استجابة غير متوقعة من الخادم"
|
||||
gotInvalidResponseErrorDescription: "يتعذر الوصول إلى الخادم أوأنه يُصان، رجاءً حاول لاحقًا."
|
||||
thisPostMayBeAnnoying: "هذا قد يزعج الآخرين."
|
||||
thisPostMayBeAnnoyingHome: "أنشر في الخط الزمني الرئيس"
|
||||
thisPostMayBeAnnoyingCancel: "ألغِ"
|
||||
internalServerError: "خطأ داخلي في الخادم"
|
||||
internalServerErrorDescription: "واجه الخادم خطأ غي متوقع."
|
||||
copyErrorInfo: "انسخ تفاصيل الخطأ"
|
||||
joinThisServer: "سجل في هذا المثيل"
|
||||
exploreOtherServers: "اعثر على مثيل آخر"
|
||||
disableFederationOk: "عطّل"
|
||||
invitationRequiredToRegister: "هذا المثيل للمدعوين فقط. لتسجيل فيه تحتاج رمزًا صالحًا."
|
||||
postToTheChannel: "انشر في قناة"
|
||||
cannotBeChangedLater: "لا يمكن تغييره لاحقًا."
|
||||
reactionAcceptance: "قبول التفاعلات"
|
||||
rolesAssignedToMe: "الأدوار المسندة إلي"
|
||||
resetPasswordConfirm: "هل تريد إعادة تعيين كلمة السر؟"
|
||||
noteIdOrUrl: "معرف الملاحظة أو رابطها"
|
||||
video: "فيديو"
|
||||
videos: "فيديوهات"
|
||||
accountMigration: "ترحيل الحساب"
|
||||
accountMoved: "نقل هذا المستخدم حسابه:"
|
||||
accountMovedShort: "رُحل هذا الحساب."
|
||||
operationForbidden: "عملية ممنوعة"
|
||||
forceShowAds: "أظهر الإعلانات التجارية دائما"
|
||||
vertical: "عمودي"
|
||||
horizontal: "جانبي"
|
||||
position: "الموضع"
|
||||
serverRules: "قوانين الخادم"
|
||||
pleaseConfirmBelowBeforeSignup: "رجاءً وافق على ما يلي قبل التسجيل."
|
||||
pleaseAgreeAllToContinue: "للمتابعة وافق على الحقول أعلاه."
|
||||
continue: "متابعة"
|
||||
preservedUsernames: "أسماء المستخدمين المحجوزة"
|
||||
preservedUsernamesDescription: "قائمة بأسماء المستخدمين المحجوزة كلٌ في سطر. لن يُقبل التسجيل بهذه الأسماء وستبقى محصورة على التسجيل اليدوي بواسطة المديرين. لن يتأثر المستخدمون الذين يملكون هذه الأسماء سلفًا."
|
||||
archive: "الأرشيف"
|
||||
youFollowing: "متابَع"
|
||||
options: "خيارات"
|
||||
_role:
|
||||
new: "دور جديد"
|
||||
edit: "حرر الأدوار"
|
||||
name: "اسم الدور"
|
||||
description: "وصف الدور"
|
||||
permission: "أذونات الدور"
|
||||
assignTarget: "نوع الإسناد"
|
||||
options: "خيارات"
|
||||
policies: "السياسة العامة"
|
||||
priority: "الأولوية"
|
||||
_priority:
|
||||
low: "منخفضة"
|
||||
middle: "متوسط"
|
||||
high: "عالية"
|
||||
_options:
|
||||
canManageCustomEmojis: "إدارة الإيموجي المخصصة"
|
||||
_condition:
|
||||
isLocal: "مستخدم محلي"
|
||||
isRemote: "مستخدم بعيد"
|
||||
_emailUnavailable:
|
||||
used: "هذا البريد الإلكتروني مستخدم"
|
||||
format: "صيغة البريد الإلكتروني غير صالحة"
|
||||
|
@ -1064,6 +1186,7 @@ _widgets:
|
|||
onlineUsers: "المتّصلون"
|
||||
jobQueue: "قائمة الانتظار"
|
||||
serverMetric: "إحصائيات الخادم"
|
||||
userList: "قائمة المستخدمين"
|
||||
_userList:
|
||||
chooseList: "اختر قائمة"
|
||||
_cw:
|
||||
|
@ -1127,6 +1250,7 @@ _profile:
|
|||
changeBanner: "غيّر اللافتة"
|
||||
_exportOrImport:
|
||||
allNotes: "كل الملاحظات"
|
||||
favoritedNotes: " الملاحظات المفضلة"
|
||||
followingList: "المتابَعون"
|
||||
muteList: "المستخدمون المكتومون"
|
||||
blockingList: "المستخدمون المحجوبون"
|
||||
|
@ -1145,6 +1269,8 @@ _charts:
|
|||
notesTotal: "إجمالي الملاحظات"
|
||||
filesIncDec: "تباين عدد الملفات"
|
||||
filesTotal: "العدد الإجمالي للملفات"
|
||||
storageUsageIncDec: "التباين في استغلال مساحة التخزين"
|
||||
storageUsageTotal: "اجمالي مساحة التخزين المستغلة"
|
||||
_instanceCharts:
|
||||
requests: "الطلبات"
|
||||
users: "تباين عدد المستخدمين"
|
||||
|
@ -1264,3 +1390,5 @@ _deck:
|
|||
_webhookSettings:
|
||||
name: "الإسم"
|
||||
active: "مفعّل"
|
||||
_events:
|
||||
reaction: "عند تلقي تفاعل"
|
||||
|
|
|
@ -52,6 +52,8 @@ addToList: "Zu Liste hinzufügen"
|
|||
sendMessage: "Nachricht senden"
|
||||
copyRSS: "RSS kopieren"
|
||||
copyUsername: "Benutzernamen kopieren"
|
||||
copyUserId: "Benutzer-ID kopieren"
|
||||
copyNoteId: "Notiz-ID kopieren"
|
||||
searchUser: "Nach einem Benutzer suchen"
|
||||
reply: "Antworten"
|
||||
loadMore: "Mehr laden"
|
||||
|
@ -790,6 +792,7 @@ noMaintainerInformationWarning: "Betreiberinformationen sind nicht konfiguriert.
|
|||
noBotProtectionWarning: "Schutz vor Bots ist nicht konfiguriert."
|
||||
configure: "Konfigurieren"
|
||||
postToGallery: "Neuen Galeriebeitrag erstellen"
|
||||
postToHashtag: "Mit diesem Hashtag senden"
|
||||
gallery: "Galerie"
|
||||
recentPosts: "Neue Beiträge"
|
||||
popularPosts: "Beliebte Beiträge"
|
||||
|
@ -823,6 +826,7 @@ translatedFrom: "Aus {x} übersetzt"
|
|||
accountDeletionInProgress: "Die Löschung deines Benutzerkontos ist momentan in Bearbeitung."
|
||||
usernameInfo: "Ein Name, durch den dein Benutzerkonto auf diesem Server identifiziert werden kann. Du kannst das Alphabet (a~z, A~Z), Ziffern (0~9) oder Unterstriche (_) verwenden. Benutzernamen können später nicht geändert werden."
|
||||
aiChanMode: "Ai-Modus"
|
||||
devMode: "Entwicklermodus"
|
||||
keepCw: "Inhaltswarnungen beibehalten"
|
||||
pubSub: "Pub/Sub Benutzerkonten"
|
||||
lastCommunication: "Letzte Kommunikation"
|
||||
|
@ -832,6 +836,8 @@ breakFollow: "Follower entfernen"
|
|||
breakFollowConfirm: "Diesen Follower wirklich entfernen?"
|
||||
itsOn: "Eingeschaltet"
|
||||
itsOff: "Ausgeschaltet"
|
||||
on: "An"
|
||||
off: "Aus"
|
||||
emailRequiredForSignup: "Angabe einer Email-Adresse als benötigt markieren"
|
||||
unread: "Ungelesen"
|
||||
filter: "Filter"
|
||||
|
@ -986,6 +992,8 @@ cannotBeChangedLater: "Kann später nicht mehr geändert werden."
|
|||
reactionAcceptance: "Reaktionsannahme"
|
||||
likeOnly: "Nur \"Gefällt mir\""
|
||||
likeOnlyForRemote: "Nur \"Gefällt mir\" für fremde Instanzen"
|
||||
nonSensitiveOnly: "Keine Sensitiven"
|
||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Keine Sensitiven (Nur \"Gefällt mir\" von fremden Instanzen)"
|
||||
rolesAssignedToMe: "Mir zugewiesene Rollen"
|
||||
resetPasswordConfirm: "Wirklich Passwort zurücksetzen?"
|
||||
sensitiveWords: "Sensible Wörter"
|
||||
|
@ -1043,6 +1051,15 @@ preventAiLearning: "Verwendung in machinellem Lernen (Generative bzw. Prediktive
|
|||
preventAiLearningDescription: "Fordert Crawler auf, gepostetes Text- oder Bildmaterial usw. nicht in Datensätzen für maschinelles Lernen (Generative bzw. Prediktive AI/KI) zu verwenden. Dies wird durch das Hinzufügen einer \"noai\"-Flag in der HTML-Antwort des jeweiligen Inhalts erreicht. Da diese Flag jedoch ignoriert werden kann, ist eine vollständige Verhinderung hierdurch nicht möglich."
|
||||
options: "Optionen"
|
||||
specifyUser: "Spezifischer Benutzer"
|
||||
failedToPreviewUrl: "Vorschau nicht anzeigbar"
|
||||
update: "Aktualisieren"
|
||||
rolesThatCanBeUsedThisEmojiAsReaction: "Rollen, die dieses Emoji als Reaktion verwenden können"
|
||||
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Sind keine Rollen angegeben, kann jeder dieses Emoji als Reaktion verwenden."
|
||||
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Diese Rollen müssen öffentlich sein."
|
||||
cancelReactionConfirm: "Möchtest du deine Reaktion wirklich löschen?"
|
||||
changeReactionConfirm: "Möchtest du deine Reaktion wirklich ändern?"
|
||||
later: "Später"
|
||||
goToMisskey: "Zu Misskey"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Dein Konto wurde erfolgreich erstellt!"
|
||||
letsStartAccountSetup: "Lass uns nun dein Konto einrichten."
|
||||
|
@ -1057,6 +1074,7 @@ _initialAccountSetting:
|
|||
haveFun: "Viel Spaß mit {name}!"
|
||||
ifYouNeedLearnMore: "Besuche {link}, falls du mehr über {name} (Misskey) lernen möchtest."
|
||||
skipAreYouSure: "Die Kontoeinrichtung wirklich überspringen?"
|
||||
laterAreYouSure: "Die Kontoeinrichtung wirklich später erledigen?"
|
||||
_serverRules:
|
||||
description: "Eine Reihe von Regeln, die vor der Registrierung angezeigt werden. Eine Zusammenfassung der Nutzungsbedingungen anzuzeigen ist empfohlen."
|
||||
_accountMigration:
|
||||
|
|
|
@ -52,6 +52,8 @@ addToList: "Add to list"
|
|||
sendMessage: "Send a message"
|
||||
copyRSS: "Copy RSS"
|
||||
copyUsername: "Copy username"
|
||||
copyUserId: "Copy user ID"
|
||||
copyNoteId: "Copy note ID"
|
||||
searchUser: "Search for a user"
|
||||
reply: "Reply"
|
||||
loadMore: "Load more"
|
||||
|
@ -790,6 +792,7 @@ noMaintainerInformationWarning: "Maintainer information is not configured."
|
|||
noBotProtectionWarning: "Bot protection is not configured."
|
||||
configure: "Configure"
|
||||
postToGallery: "Create new gallery post"
|
||||
postToHashtag: "Post to this hashtag"
|
||||
gallery: "Gallery"
|
||||
recentPosts: "Recent posts"
|
||||
popularPosts: "Popular posts"
|
||||
|
@ -823,6 +826,7 @@ translatedFrom: "Translated from {x}"
|
|||
accountDeletionInProgress: "Account deletion is currently in progress"
|
||||
usernameInfo: "A name that identifies your account from others on this server. You can use the alphabet (a~z, A~Z), digits (0~9) or underscores (_). Usernames cannot be changed later."
|
||||
aiChanMode: "Ai Mode"
|
||||
devMode: "Developer mode"
|
||||
keepCw: "Keep content warnings"
|
||||
pubSub: "Pub/Sub Accounts"
|
||||
lastCommunication: "Last communication"
|
||||
|
@ -832,6 +836,8 @@ breakFollow: "Remove follower"
|
|||
breakFollowConfirm: "Really remove this follower?"
|
||||
itsOn: "Enabled"
|
||||
itsOff: "Disabled"
|
||||
on: "On"
|
||||
off: "Off"
|
||||
emailRequiredForSignup: "Require email address for sign-up"
|
||||
unread: "Unread"
|
||||
filter: "Filter"
|
||||
|
@ -986,6 +992,8 @@ cannotBeChangedLater: "This cannot be changed later."
|
|||
reactionAcceptance: "Reaction Acceptance"
|
||||
likeOnly: "Only likes"
|
||||
likeOnlyForRemote: "Only likes for remote instances"
|
||||
nonSensitiveOnly: "Non-sensitive only"
|
||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "Non-sensitive only (Only likes from remote)"
|
||||
rolesAssignedToMe: "Roles assigned to me"
|
||||
resetPasswordConfirm: "Really reset your password?"
|
||||
sensitiveWords: "Sensitive words"
|
||||
|
@ -1043,6 +1051,15 @@ preventAiLearning: "Reject usage in Machine Learning (Generative AI)"
|
|||
preventAiLearningDescription: "Requests crawlers to not use posted text or image material etc. in machine learning (Predictive / Generative AI) data sets. This is achieved by adding a \"noai\" HTML-Response flag to the respective content. A complete prevention can however not be achieved through this flag, as it may simply be ignored."
|
||||
options: "Options"
|
||||
specifyUser: "Specific user"
|
||||
failedToPreviewUrl: "Could not preview"
|
||||
update: "Update"
|
||||
rolesThatCanBeUsedThisEmojiAsReaction: "Roles that can use this emoji as reaction"
|
||||
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "If no roles are specified, anyone can use this emoji as reaction."
|
||||
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "These roles must be public."
|
||||
cancelReactionConfirm: "Really delete your reaction?"
|
||||
changeReactionConfirm: "Really change your reaction?"
|
||||
later: "Later"
|
||||
goToMisskey: "To Misskey"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Your account was successfully created!"
|
||||
letsStartAccountSetup: "For starters, let's set up your profile."
|
||||
|
@ -1057,6 +1074,7 @@ _initialAccountSetting:
|
|||
haveFun: "Enjoy {name}!"
|
||||
ifYouNeedLearnMore: "If you'd like to learn more about how to use {name} (Misskey), please visit {link}."
|
||||
skipAreYouSure: "Really skip profile setup?"
|
||||
laterAreYouSure: "Really do profile setup later?"
|
||||
_serverRules:
|
||||
description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended."
|
||||
_accountMigration:
|
||||
|
|
|
@ -20,6 +20,7 @@ noNotes: "No hay notas"
|
|||
noNotifications: "No hay notificaciones"
|
||||
instance: "Instancia"
|
||||
settings: "Configuración"
|
||||
notificationSettings: "Configurar las notificaciones"
|
||||
basicSettings: "Configuración Básica"
|
||||
otherSettings: "Configuración avanzada"
|
||||
openInWindow: "Abrir en una ventana"
|
||||
|
@ -262,8 +263,10 @@ noMoreHistory: "El historial se ha acabado"
|
|||
startMessaging: "Iniciar chat"
|
||||
nUsersRead: "Leído por {n} personas"
|
||||
agreeTo: "De acuerdo con {0}"
|
||||
agree: "De acuerdo."
|
||||
agreeBelow: "Estoy de acuerdo con lo siguiente"
|
||||
basicNotesBeforeCreateAccount: "Notas básicas"
|
||||
termsOfService: "Términos y condiciones"
|
||||
start: "Comenzar"
|
||||
home: "Inicio"
|
||||
remoteUserCaution: "Para el usuario remoto, la información está incompleta"
|
||||
|
@ -473,6 +476,8 @@ createAccount: "Crear cuenta"
|
|||
existingAccount: "Cuenta existente"
|
||||
regenerate: "Regenerar"
|
||||
fontSize: "Tamaño de la letra"
|
||||
mediaListWithOneImageAppearance: "Altura de la lista de medios con una sola imagen."
|
||||
limitTo: "{x} hasta un máximo de"
|
||||
noFollowRequests: "No hay solicitudes de seguimiento"
|
||||
openImageInNewTab: "Abrir imagen en nueva pestaña"
|
||||
dashboard: "Panel de control"
|
||||
|
@ -555,6 +560,7 @@ accountDeletedDescription: "Esta cuenta ha sido borrada."
|
|||
menu: "Menú"
|
||||
divider: "Divisor"
|
||||
addItem: "Agregar elemento"
|
||||
rearrange: "Ordenar"
|
||||
relays: "Relés"
|
||||
addRelay: "Agregar relé"
|
||||
inboxUrl: "Inbox URL"
|
||||
|
@ -698,6 +704,8 @@ contact: "Contacto"
|
|||
useSystemFont: "Utilizar la tipografía por defecto del sistema"
|
||||
clips: "Clip"
|
||||
experimentalFeatures: "Características experimentales"
|
||||
experimental: "Función experimental"
|
||||
thisIsExperimentalFeature: "Se trata de una función experimental. Las especificaciones pueden cambiar o puede que no funcione correctamente."
|
||||
developer: "Desarrolladores"
|
||||
makeExplorable: "Hacer visible la cuenta en \"Explorar\""
|
||||
makeExplorableDescription: "Si desactiva esta opción, su cuenta no aparecerá en la sección \"Explorar\"."
|
||||
|
@ -991,9 +999,12 @@ largeNoteReactions: "Agrandar las reacciones de las notas"
|
|||
noteIdOrUrl: "ID o URL de la nota"
|
||||
accountMigration: "Migración de cuenta"
|
||||
accountMoved: "Este usuario se ha mudado a una nueva cuenta:"
|
||||
accountMovedShort: "Esta cuenta ha sido migrada."
|
||||
horizontal: "Horizontal"
|
||||
youFollowing: "Siguiendo"
|
||||
options: "Opción"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "¡La cuenta ha sido creada!"
|
||||
_accountMigration:
|
||||
moveFrom: "Trasladar de otra cuenta a ésta"
|
||||
moveFromLabel: "Cuenta desde la que se realiza el traslado:"
|
||||
|
@ -1174,6 +1185,8 @@ _achievements:
|
|||
_client30min:
|
||||
title: "Un descansito"
|
||||
description: "30 minutos dedicados a Misskey"
|
||||
_client60min:
|
||||
title: "Viendo mucho Misskey."
|
||||
_noteDeletedWithin1min:
|
||||
title: "Ah... Mejor no..."
|
||||
description: "Borrar una nota antes que de pase 1 minuto"
|
||||
|
|
|
@ -908,12 +908,14 @@ neverShow: "Ne plus afficher"
|
|||
remindMeLater: "Peut-être plus tard"
|
||||
roles: "Rôles"
|
||||
role: "Rôles"
|
||||
noRole: "Aucun rôle"
|
||||
normalUser: "Simple utilisateur·rice"
|
||||
assign: "Attribuer"
|
||||
color: "Couleur"
|
||||
manageCustomEmojis: "Gestion des émojis personnalisés"
|
||||
preset: "Préréglage"
|
||||
selectFromPresets: "Sélectionner à partir des préréglages"
|
||||
thisPostMayBeAnnoying: "Cette note peut gêner d'autres personnes."
|
||||
thisPostMayBeAnnoyingCancel: "Annuler"
|
||||
license: "Licence"
|
||||
video: "Vidéo"
|
||||
|
@ -921,6 +923,7 @@ videos: "Vidéos"
|
|||
dataSaver: "Économiseur de données"
|
||||
accountMigration: "Migration de compte"
|
||||
accountMoved: "Cet·te utilisateur·rice a migré son compte vers :"
|
||||
addMemo: "Ajouter un mémo"
|
||||
notificationDisplay: "Style des notifications"
|
||||
leftTop: "En haut à gauche"
|
||||
rightTop: "En haut à droite"
|
||||
|
@ -935,6 +938,8 @@ _achievements:
|
|||
_notes1:
|
||||
description: "Publiez votre première note"
|
||||
flavor: "Passez un bon moment avec Misskey !"
|
||||
_notes100:
|
||||
title: "Beaucoup de notes"
|
||||
_notes100000:
|
||||
title: "ALL YOUR NOTE ARE BELONG TO US"
|
||||
_login3:
|
||||
|
@ -985,6 +990,8 @@ _achievements:
|
|||
title: "Joyeux Anniversaire !"
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "Bonne année !"
|
||||
_cookieClicked:
|
||||
flavor: "Attendez une minute, vous êtes sur le mauvais site web ?"
|
||||
_role:
|
||||
assignTarget: "Attribuer"
|
||||
priority: "Priorité"
|
||||
|
|
|
@ -20,6 +20,7 @@ noNotes: "Tidak ada catatan"
|
|||
noNotifications: "Tidak ada pemberitahuan"
|
||||
instance: "Instansi"
|
||||
settings: "Pengaturan"
|
||||
notificationSettings: "Atur Notifikasi"
|
||||
basicSettings: "Pengaturan umum"
|
||||
otherSettings: "Pengaturan lainnya"
|
||||
openInWindow: "Buka di jendela"
|
||||
|
@ -261,8 +262,10 @@ noMoreHistory: "Tidak ada sejarah lagi"
|
|||
startMessaging: "Mulai mengirim pesan"
|
||||
nUsersRead: "Dibaca oleh {n}"
|
||||
agreeTo: "Saya setuju kepada {0}"
|
||||
agree: "Setuju"
|
||||
agreeBelow: "Saya setuju dengan di bawah ini"
|
||||
basicNotesBeforeCreateAccount: "Catatan penting"
|
||||
termsOfService: "Syarat dan ketentuan"
|
||||
start: "Mulai"
|
||||
home: "Beranda"
|
||||
remoteUserCaution: "Informasi ini mungkin tidak mutakhir, karena pengguna ini berasal dari instansi luar."
|
||||
|
|
|
@ -8,7 +8,7 @@ search: "探す"
|
|||
notifications: "通知"
|
||||
username: "ユーザー名"
|
||||
password: "パスワード"
|
||||
forgotPassword: "パスワード忘れてもうた"
|
||||
forgotPassword: "パスワード忘れたん?"
|
||||
fetchingAsApObject: "今ちと連合に照会しとるで"
|
||||
ok: "ええで"
|
||||
gotIt: "ほい"
|
||||
|
|
|
@ -505,7 +505,7 @@ objectStoragePrefixDesc: "이 Prefix 의 디렉토리 아래에 파일이 저장
|
|||
objectStorageEndpoint: "Endpoint"
|
||||
objectStorageEndpointDesc: "AWS S3의 경우 공란, 다른 서비스의 경우 각 서비스의 가이드에 맞게 endpoint를 설정해주세요. '<host>' 혹은 '<host>:<port>' 와 같이 지정합니다."
|
||||
objectStorageRegion: "Region"
|
||||
objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해주세요. 사용하는 서비스에 region 개념이 없는 경우, 비워 두거나 'us-east-1'으로 설정해 주세요."
|
||||
objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해주세요. 사용하는 서비스에 region 개념이 없는 경우 'us-east-1'으로 설정해 주세요. AWS 설정 파일 또는 환경 변수를 참조할 경우에는 비워주세요."
|
||||
objectStorageUseSSL: "SSL 사용"
|
||||
objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF 로 설정해 주세요"
|
||||
objectStorageUseProxy: "연결에 프록시를 사용"
|
||||
|
@ -990,6 +990,7 @@ rolesAssignedToMe: "나에게 할당된 역할"
|
|||
resetPasswordConfirm: "비밀번호를 재설정하시겠습니까?"
|
||||
sensitiveWords: "민감한 단어"
|
||||
sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다."
|
||||
sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다."
|
||||
notesSearchNotAvailable: "노트 검색을 이용하실 수 없습니다."
|
||||
license: "라이선스"
|
||||
unfavoriteConfirm: "즐겨찾기를 해제하시겠습니까?"
|
||||
|
@ -1038,12 +1039,16 @@ thisChannelArchived: "이 채널은 아카이브되었습니다."
|
|||
displayOfNote: "노트 표시"
|
||||
initialAccountSetting: "초기 설정"
|
||||
youFollowing: "팔로잉"
|
||||
preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부"
|
||||
preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다."
|
||||
options: "옵션"
|
||||
specifyUser: "사용자 지정"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "계정 생성이 완료되었습니다!"
|
||||
letsStartAccountSetup: "계정의 초기 설정을 진행합니다."
|
||||
letsFillYourProfile: "우선 나의 프로필을 설정해 보아요."
|
||||
profileSetting: "프로필 설정"
|
||||
privacySetting: "\n프라이버시설정"
|
||||
theseSettingsCanEditLater: "이 설정들은 나중에도 변경할 수 있습니다."
|
||||
youCanEditMoreSettingsInSettingsPageLater: "이 외에도 '설정' 페이지에서 다양한 설정을 나의 입맛에 맛게 조절할 수 있습니다. 꼭 확인해 보세요!"
|
||||
followUsers: "관심사가 맞는 유저를 팔로우하여 타임라인을 가꾸어 봅시다."
|
||||
|
@ -1491,7 +1496,7 @@ _menuDisplay:
|
|||
hide: "숨기기"
|
||||
_wordMute:
|
||||
muteWords: "뮤트할 단어"
|
||||
muteWordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다。"
|
||||
muteWordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다."
|
||||
muteWordsDescription2: "정규 표현식을 사용하려면 키워드를 빗금표(/)로 감싸 주세요."
|
||||
softDescription: "지정한 조건의 노트를 타임라인에서 숨깁니다."
|
||||
hardDescription: "지정한 조건의 노트를 타임라인에 추가하지 않습니다. 타임라인에 추가되지 않은 노트는 조건을 변경해도 표시되지 않습니다."
|
||||
|
|
|
@ -1,21 +1,27 @@
|
|||
---
|
||||
_lang_: "Norsk Bokmål"
|
||||
monthAndDay: "{day}-{month}"
|
||||
search: "Søk"
|
||||
notifications: "Varsler"
|
||||
username: "Brukernavn"
|
||||
password: "Passord"
|
||||
forgotPassword: "Glemt passord"
|
||||
fetchingAsApObject: "Henter fra Fediverse..."
|
||||
ok: "OK"
|
||||
gotIt: "Skjønner"
|
||||
cancel: "Avbryt"
|
||||
noThankYou: "Avbryt"
|
||||
enterUsername: "Skriv inn brukernavn"
|
||||
noNotifications: "Ingen varsler"
|
||||
instance: "Server"
|
||||
settings: "Innstillinger"
|
||||
notificationSettings: "Varslingsinnstillinger"
|
||||
basicSettings: "Grunnleggende innstillinger"
|
||||
otherSettings: "Andre innstillinger"
|
||||
openInWindow: "Åpne i vindu"
|
||||
profile: "Profil"
|
||||
timeline: "Tidslinje"
|
||||
noAccountDescription: "Denne brukeren har ikke skrevet sin bio ennå."
|
||||
login: "Logg inn"
|
||||
loggingIn: "Logget inn"
|
||||
logout: "Logg ut"
|
||||
|
@ -27,6 +33,9 @@ addUser: "Legg til bruker"
|
|||
favorite: "Favoritt"
|
||||
favorites: "Favoritter"
|
||||
unfavorite: "Fjern favoritt"
|
||||
favorited: "Lagt til i favoritter."
|
||||
alreadyFavorited: "Allerede lagt til i favoritter."
|
||||
cantFavorite: "Kunne ikke legge til i favoritter."
|
||||
pin: "Fest"
|
||||
unpin: "Opphev festing"
|
||||
copyContent: "Kopier innhold"
|
||||
|
@ -42,8 +51,16 @@ reply: "Svar"
|
|||
loadMore: "Vis mer"
|
||||
showMore: "Vis mer"
|
||||
showLess: "Lukk"
|
||||
youGotNewFollower: "fulgte deg"
|
||||
followRequestAccepted: "Følgeforespørsel akseptert"
|
||||
importAndExport: "Importer og eksporter"
|
||||
import: "Importer"
|
||||
export: "Eksporter"
|
||||
files: "Filer"
|
||||
download: "Nedlastinger"
|
||||
driveFileDeleteConfirm: "Er du sikker på at du vil slette \"{name}\"? Det vil også forsvinne fra alt innhold som bruker det."
|
||||
unfollowConfirm: "Er du sikker på at du vil slutte å følge {name}?"
|
||||
importRequested: "Du har bedt om import. Dette kan ta en stund."
|
||||
lists: "Lister"
|
||||
noLists: "Ingen lister"
|
||||
following: "Følg"
|
||||
|
@ -51,14 +68,18 @@ followers: "Følgere"
|
|||
followsYou: "Følger deg"
|
||||
createList: "Opprett liste"
|
||||
error: "Feil"
|
||||
somethingHappened: "En feil har oppstått"
|
||||
retry: "Prøv igjen"
|
||||
pageLoadError: "Kunne ikke hente side."
|
||||
serverIsDead: "Denne serveren svarer ikke. Vennligst vent en stund og prøv igjen."
|
||||
enterListName: "Skriv inn et navn på listen"
|
||||
privacy: "Personvern"
|
||||
follow: "Følg"
|
||||
followRequest: "Følgeforespørsel"
|
||||
followRequests: "Følgeforespørsel"
|
||||
unfollow: "Avfølg"
|
||||
followRequestPending: "Venter på godkjenning"
|
||||
enterEmoji: "Skriv inn en emoji"
|
||||
quote: "Sitat"
|
||||
pinned: "Fest"
|
||||
you: "Du"
|
||||
|
@ -66,49 +87,90 @@ clickToShow: "Klikk for å vise"
|
|||
add: "Legg til"
|
||||
reaction: "Reaksjon"
|
||||
reactions: "Reaksjoner"
|
||||
reactionSetting: "Reaksjoner som vises i reaksjonsvelgeren"
|
||||
reactionSettingDescription2: "Dra for å endre rekkefølgen, klikk for å slette, trykk \"+\" for å legge til."
|
||||
attachCancel: "Fjern vedlegg"
|
||||
enterFileName: "Skriv inn filnavn"
|
||||
mute: "Skjul"
|
||||
unmute: "Vis"
|
||||
block: "Blokker"
|
||||
unblock: "Opphev blokkering"
|
||||
suspend: "Suspender"
|
||||
blockConfirm: "Blokker?"
|
||||
unblockConfirm: "Er du sikker på at du vil oppheve blokkeringen av denne kontoen?"
|
||||
suspendConfirm: "Er du sikker på at du vil suspendere denne kontoen?"
|
||||
selectList: "Velg liste"
|
||||
selectChannel: "Velg kanal"
|
||||
selectAntenna: "Velg en antenne"
|
||||
selectWidget: "Velg en widget"
|
||||
editWidgets: "Rediger widgeter"
|
||||
editWidgetsExit: "Ferdig"
|
||||
emoji: "Emoji"
|
||||
emojis: "Emojier"
|
||||
addEmoji: "Legg til emoji"
|
||||
settingGuide: "Anbefalte innstillinger"
|
||||
flagAsBot: "Merk denne kontoen som en bot"
|
||||
flagAsBotDescription: "Aktiver dette alternativet hvis denne kontoen styres av et program. Hvis det er aktivert, vil det fungere som et flagg for andre utviklere for å forhindre endeløse interaksjonskjeder med andre roboter og justere Misskeys interne systemer til å behandle denne kontoen som en bot."
|
||||
flagAsCat: "Merk denne kontoen som en katt"
|
||||
flagAsCatDescription: "Aktiver dette alternativet for å merke denne kontoen som en katt."
|
||||
addAccount: "Legg til konto"
|
||||
reloadAccountsList: "Last inn kontoliste på nytt"
|
||||
loginFailed: "Kunne ikke logge inn"
|
||||
general: "Generelt"
|
||||
searchWith: "Søk: {q}"
|
||||
youHaveNoLists: "Du har ingen lister"
|
||||
followConfirm: "Er du sikker på at du vil følge {name}?"
|
||||
selectUser: "Velg bruker"
|
||||
recipient: "Mottaker"
|
||||
annotation: "Kommentarer"
|
||||
federation: "Føderasjon"
|
||||
instances: "Server"
|
||||
registeredAt: "Registrerte seg"
|
||||
perHour: "Per time"
|
||||
perDay: "Per dag"
|
||||
stopActivityDelivery: "Slutt å sende aktiviteter"
|
||||
blockThisInstance: "Blokker denne serveren"
|
||||
software: "Programvare"
|
||||
version: "Versjon"
|
||||
withNFiles: "{n} fil(er)"
|
||||
network: "Nettverk"
|
||||
statistics: "Statistikk"
|
||||
clearQueue: "Tøm kø"
|
||||
clearQueueConfirmTitle: "Vil du tømme kø?"
|
||||
blockedInstances: "Blokkerte severe"
|
||||
blockedInstancesDescription: "Skriv opp vertsnavnene til serverne du vil blokkere, atskilt med linjeskift. Serverne i listen vil ikke lenger kunne kommunisere med denne serveren."
|
||||
muteAndBlock: "Skjul og blokker"
|
||||
mutedUsers: "Skjulte brukere"
|
||||
blockedUsers: "Blokkerte brukere"
|
||||
noUsers: "Det er ingen brukere"
|
||||
editProfile: "Rediger profil"
|
||||
pinLimitExceeded: "Du kan ikke feste flere."
|
||||
done: "Ferdig"
|
||||
noCustomEmojis: "Ingen emoji"
|
||||
noJobs: "Det er ingen jobber"
|
||||
blocked: "Blokkert"
|
||||
suspended: "Suspendert"
|
||||
all: "Alle"
|
||||
notResponding: "Svarer ikke"
|
||||
changePassword: "Endre passord"
|
||||
security: "Sikkerhet"
|
||||
newPassword: "Nytt passord"
|
||||
newPasswordRetype: "Nytt passord (gjenta)"
|
||||
attachFile: "Legg ved filer"
|
||||
more: "Mer!"
|
||||
announcements: "Kunngjøringer"
|
||||
remove: "Slett"
|
||||
removed: "Slettet"
|
||||
removeAreYouSure: "Er du sikker på at du vil fjerne \"{x}\"?"
|
||||
deleteAreYouSure: "Er du sikker på at du vil slette \"{x}\"?"
|
||||
saved: "Lagret"
|
||||
upload: "Laste opp"
|
||||
explore: "Utforsk"
|
||||
messageRead: "Lest"
|
||||
agree: "Jeg godtar"
|
||||
termsOfService: "Vilkår for bruk"
|
||||
home: "Hjem"
|
||||
activity: "Aktivitet"
|
||||
images: "Bilder"
|
||||
image: "Bilder"
|
||||
birthday: "Bursdag"
|
||||
|
@ -127,19 +189,42 @@ renameFolder: "Endre mappenavn"
|
|||
deleteFolder: "Slett mappe"
|
||||
addFile: "Legg til fil"
|
||||
emptyFolder: "Denne mappen er tom"
|
||||
unableToDelete: "Kan ikke slette"
|
||||
circularReferenceFolder: "Målmappen er en undermappe til mappen du ønsker å flytte."
|
||||
hasChildFilesOrFolders: "Siden denne mappen ikke er tom, kan den ikke slettes."
|
||||
copyUrl: "Kopier URL"
|
||||
rename: "Endre navn"
|
||||
avatar: "Avatar"
|
||||
banner: "Banner"
|
||||
doNothing: "Gjør ingenting"
|
||||
accept: "Tillatt"
|
||||
reject: "Avslå"
|
||||
instanceName: "Servernavn"
|
||||
instanceDescription: "Serverbeskrivelse"
|
||||
thisYear: "I år"
|
||||
thisMonth: "Måned"
|
||||
today: "I dag"
|
||||
dayX: "{day}"
|
||||
monthX: "{month}"
|
||||
yearX: "{year}"
|
||||
pages: "Sider"
|
||||
integration: "Integrasjon"
|
||||
enableLocalTimeline: "Aktiver lokal tidslinje"
|
||||
enableGlobalTimeline: "Aktiver global tidslinje"
|
||||
disablingTimelinesInfo: "Administratorer og Moderatorer vil alltid ha tilgang til alle tidslinjer, selv om de ikke er aktivert."
|
||||
registration: "Registrer"
|
||||
enableRegistration: "Aktiver registrering av nye brukere"
|
||||
invite: "Inviter"
|
||||
basicInfo: "Grunnleggende informasjon"
|
||||
pinnedUsers: "Festete brukrere"
|
||||
pinnedUsersDescription: "Liste over brukernavn atskilt med linjeskift som skal festes i \"Utforsk\" fanen."
|
||||
pinnedPages: "Festete sider"
|
||||
hcaptcha: "hCaptcha"
|
||||
enableHcaptcha: "Aktiver hCaptcha"
|
||||
recaptcha: "reCAPTCHA"
|
||||
enableRecaptcha: "Aktiver reCAPTCHA"
|
||||
turnstile: "Turnstile"
|
||||
enableTurnstile: "Aktiver Turnstile"
|
||||
name: "Navn"
|
||||
popularUsers: "Populære brukere"
|
||||
exploreUsersCount: "Det finnes {count} brukere"
|
||||
|
@ -149,10 +234,13 @@ aboutMisskey: "Om Misskey"
|
|||
share: "Del"
|
||||
help: "Hjelp"
|
||||
close: "Lukk"
|
||||
invites: "Inviter"
|
||||
members: "Medlemmer"
|
||||
title: "Tittel"
|
||||
text: "Tekst"
|
||||
next: "Neste"
|
||||
retype: "Gjenta"
|
||||
invitations: "Inviter"
|
||||
available: "Tilgjengelig"
|
||||
unavailable: "Utilgjengelig"
|
||||
tooShort: "For kort"
|
||||
|
@ -234,6 +322,7 @@ manageAccounts: "Administrer konto"
|
|||
classic: "Klassisk"
|
||||
muteThread: "Skjul denne tråden"
|
||||
unmuteThread: "Vis denne tråden"
|
||||
hide: "Skjul"
|
||||
tablet: "Nettbrett"
|
||||
auto: "Automatisk"
|
||||
size: "Størrelse"
|
||||
|
@ -312,10 +401,24 @@ _achievements:
|
|||
title: "Rett og slett heldig"
|
||||
_setNameToSyuilo:
|
||||
description: "Du har satt navnet ditt til \"syuilo\""
|
||||
_passedSinceAccountCreated1:
|
||||
title: "Ett års jubileum"
|
||||
description: "Det har gått ett år siden kontoen din ble opprettet"
|
||||
_passedSinceAccountCreated2:
|
||||
title: "To års jubileum"
|
||||
description: "Det har gått to år siden kontoen din ble opprettet"
|
||||
_passedSinceAccountCreated3:
|
||||
title: "Tre års jubileum"
|
||||
description: "Det har gått tre år siden kontoen din ble opprettet"
|
||||
_loggedInOnBirthday:
|
||||
title: "Gratulerer med dagen"
|
||||
description: "Du logget inn på bursdagen din"
|
||||
_loggedInOnNewYearsDay:
|
||||
title: "Godt nytt år"
|
||||
description: "Du logget inn på årets første dag"
|
||||
_cookieClicked:
|
||||
description: "Du klikket på kjeksen"
|
||||
flavor: "Er du på riktig nettsted?"
|
||||
_brainDiver:
|
||||
title: "Brain Diver"
|
||||
flavor: "Misskey-Misskey La-Tu-Ma"
|
||||
|
@ -333,6 +436,9 @@ _ad:
|
|||
_gallery:
|
||||
like: "Liker!"
|
||||
unlike: "Liker ikke"
|
||||
_email:
|
||||
_follow:
|
||||
title: "fulgte deg"
|
||||
_preferencesBackups:
|
||||
saveNew: "Lagre som ny"
|
||||
cannotSave: "Kunne ikke lagre"
|
||||
|
@ -351,6 +457,8 @@ _channel:
|
|||
featured: "Populært"
|
||||
following: "Følger"
|
||||
nameAndDescription: "Navn og beskrivelse"
|
||||
_menuDisplay:
|
||||
hide: "Skjul"
|
||||
_wordMute:
|
||||
soft: "Myk"
|
||||
hard: "Hard"
|
||||
|
@ -397,13 +505,16 @@ _widgets:
|
|||
calendar: "Kalender"
|
||||
trends: "Populært"
|
||||
clock: "Klokke"
|
||||
activity: "Aktivitet"
|
||||
photos: "Bilder"
|
||||
federation: "Føderasjon"
|
||||
button: "Knapp"
|
||||
aiscriptApp: "AiScript App"
|
||||
userList: "Brukerliste"
|
||||
_userList:
|
||||
chooseList: "Velg liste"
|
||||
_cw:
|
||||
hide: "Skjul"
|
||||
show: "Vis mer"
|
||||
_poll:
|
||||
noOnlyOneChoice: "Trenger minst to valger."
|
||||
|
@ -431,6 +542,7 @@ _exportOrImport:
|
|||
blockingList: "Blokker"
|
||||
userLists: "Lister"
|
||||
_charts:
|
||||
federation: "Føderasjon"
|
||||
filesIncDec: "Forskjell på antall filer"
|
||||
_instanceCharts:
|
||||
users: "Forskjell på antall brukere"
|
||||
|
@ -442,14 +554,18 @@ _play:
|
|||
new: "Opprett Play"
|
||||
edit: "Rediger Play"
|
||||
featured: "Populært"
|
||||
title: "Tittel"
|
||||
summary: "Beskrivelse"
|
||||
_pages:
|
||||
invalidNameText: "Pass på at sidetittelen ikke er tom"
|
||||
like: "Liker"
|
||||
unlike: "Liker ikke"
|
||||
my: "Mine sider"
|
||||
featured: "Populært"
|
||||
contents: "Innhold"
|
||||
title: "Tittel"
|
||||
url: "Side URL"
|
||||
hideTitleWhenPinned: "Skjul sidetittel når festet til profil"
|
||||
fontSerif: "Serif"
|
||||
fontSansSerif: "Sans Serif"
|
||||
selectType: "Velg type"
|
||||
|
@ -459,6 +575,7 @@ _pages:
|
|||
image: "Bilde"
|
||||
button: "Knapp"
|
||||
_notification:
|
||||
youWereFollowed: "fulgte deg"
|
||||
_types:
|
||||
follow: "Følg"
|
||||
reply: "Svar"
|
||||
|
|
|
@ -560,6 +560,7 @@ accountDeletedDescription: "Эта учетная запись удалена"
|
|||
menu: "Меню"
|
||||
divider: "Линия-разделитель"
|
||||
addItem: "Добавить элемент"
|
||||
rearrange: "Сортировать по"
|
||||
relays: "Ретрансляторы"
|
||||
addRelay: "Добавить ретранслятор"
|
||||
inboxUrl: "URL ящика входящих сообщений"
|
||||
|
@ -989,6 +990,7 @@ rolesAssignedToMe: "Мои роли"
|
|||
resetPasswordConfirm: "Сбросить пароль?"
|
||||
sensitiveWords: "Чувствительные слова"
|
||||
sensitiveWordsDescription: "Установите общедоступный диапазон заметки, содержащей заданное слово, на домашний. Можно сделать несколько настроек, разделив их переносами строк."
|
||||
sensitiveWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение."
|
||||
notesSearchNotAvailable: "Поиск заметок недоступен"
|
||||
license: "Лицензия"
|
||||
unfavoriteConfirm: "Удалить избранное?"
|
||||
|
|
|
@ -560,6 +560,7 @@ accountDeletedDescription: "บัญชีนี้ถูกลบไปแล
|
|||
menu: "เมนู"
|
||||
divider: "ตัวแบ่ง"
|
||||
addItem: "เพิ่มรายการ"
|
||||
rearrange: "จัดใหม่"
|
||||
relays: "รีเลย์"
|
||||
addRelay: "เพิ่มรีเลย์"
|
||||
inboxUrl: "อินบ็อกซ์ URL"
|
||||
|
@ -1030,6 +1031,7 @@ continue: "ดำเนินการต่อ"
|
|||
preservedUsernames: "ชื่อผู้ใช้ที่สงวนไว้"
|
||||
preservedUsernamesDescription: "ลิสต์ชื่อผู้ใช้ที่จะสำรองโดยคั่นด้วยการแบ่งบรรทัดนั้น เพราะสิ่งเหล่านี้จะไม่สามารถทำได้ในระหว่างการสร้างบัญชีตามปกติ บัญชีที่มีอยู่แล้วนั้นโดยใช้ชื่อผู้ใช้เหล่านี้จะไม่ได้รับผลกระทบอะไร"
|
||||
createNoteFromTheFile: "เรียบเรียงโน้ตจากไฟล์นี้"
|
||||
archive: "เก็บถาวร"
|
||||
youFollowing: "ติดตามแล้ว"
|
||||
options: "ตัวเลือกบทบาท"
|
||||
_serverRules:
|
||||
|
@ -1329,6 +1331,7 @@ _role:
|
|||
canInvite: "สร้างรหัสเชิญอินสแตนซ์"
|
||||
canManageCustomEmojis: "จัดการอีโมจิแบบกำหนดเอง"
|
||||
driveCapacity: "ความจุของไดรฟ์"
|
||||
alwaysMarkNsfw: "ทำเครื่องหมายไฟล์ว่าเป็น NSFW เสมอ"
|
||||
pinMax: "จํานวนสูงสุดของโน้ตที่ปักหมุดไว้"
|
||||
antennaMax: "จำนวนสูงสุดของเสาอากาศ"
|
||||
wordMuteMax: "จำนวนอักขระสูงสุดที่อนุญาตในการปิดเสียงคำ"
|
||||
|
@ -1580,6 +1583,12 @@ _time:
|
|||
minute: "นาที"
|
||||
hour: "ชั่วโมง"
|
||||
day: "วัน"
|
||||
_timelineTutorial:
|
||||
title: "วิธีใช้งาน Misskey"
|
||||
step3_1: "เสร็จสิ้นการโพสต์โน้ตย่อแรกของคุณแล้วอย่างงั้นหรอ?"
|
||||
step3_2: "ไชโย! ตอนนี้โน้ตย่อแรกของคุณได้ปรากฏบนไทม์ไลน์ของคุณแล้วนะ"
|
||||
step4_1: "คุณยังสามารถแนบ \"ปฏิกิริยา\" ไปกับโน้ตได้อีกด้วยนะค่ะ"
|
||||
step4_2: "หากต้องการแนบการแสดงความรู้สึก ให้กดเครื่องหมาย \"+\" บนโน้ตแล้วเลือกอิโมจิที่คุณต้องการแสดงความรู้สึกที่ตนเองชอบได้เลย"
|
||||
_2fa:
|
||||
alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว"
|
||||
registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์"
|
||||
|
|
|
@ -560,6 +560,7 @@ accountDeletedDescription: "此帐户已经被删除。"
|
|||
menu: "菜单"
|
||||
divider: "分割线"
|
||||
addItem: "添加项目"
|
||||
rearrange: "排序方式"
|
||||
relays: "中继"
|
||||
addRelay: "添加中继"
|
||||
inboxUrl: "Inbox URL"
|
||||
|
@ -989,6 +990,7 @@ rolesAssignedToMe: "指派给自己的角色"
|
|||
resetPasswordConfirm: "确定重置密码?"
|
||||
sensitiveWords: "敏感词"
|
||||
sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。"
|
||||
sensitiveWordsDescription2: "用空格分割关键词作为AND格式,用斜线包裹关键字来构成正则表达式。"
|
||||
notesSearchNotAvailable: "帖子检索不可用"
|
||||
license: "许可信息"
|
||||
unfavoriteConfirm: "确定要取消收藏吗?"
|
||||
|
@ -1028,28 +1030,49 @@ pleaseConfirmBelowBeforeSignup: "在这个服务器上注册账号前,请确
|
|||
pleaseAgreeAllToContinue: "必须全部勾选「同意」才能够继续。"
|
||||
continue: "继续"
|
||||
preservedUsernames: "保留的用户名"
|
||||
preservedUsernamesDescription: "列出需要保留的用户名,使用换行来作为分割。被指定的用户名在建立账户时无法使用,但由管理员所创建的账户不受该限制。此外,现有的账户也不会受到影响。"
|
||||
createNoteFromTheFile: "从文件创建帖子"
|
||||
archive: "归档"
|
||||
channelArchiveConfirmTitle: "要将{name}归档吗?"
|
||||
channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。"
|
||||
thisChannelArchived: "该频道已被归档。"
|
||||
displayOfNote: "显示帖子"
|
||||
initialAccountSetting: "初始设置"
|
||||
youFollowing: "正在关注"
|
||||
preventAiLearning: "拒绝接受生成式AI的学习"
|
||||
preventAiLearningDescription: "要求文章生成AI或图像生成AI不能够以发布的帖子和图像等内容作为学习对象。这是通过在HTML响应中包含noai标志来实现的,这不能完全阻止AI学习你的发布内容,并不是所有AI都会遵守这类请求。"
|
||||
options: "选项"
|
||||
specifyUser: "用户指定"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "账户创建完成了!"
|
||||
letsStartAccountSetup: "来进行帐户的初始设置吧。"
|
||||
letsFillYourProfile: "首先,来设定你的个人档案吧!"
|
||||
profileSetting: "个人资料设置"
|
||||
privacySetting: "隐私设置"
|
||||
theseSettingsCanEditLater: "也可以在稍后修改这里的设置。"
|
||||
youCanEditMoreSettingsInSettingsPageLater: "还可以在「设置」页面进行其它各种设置,稍后就来确认一下看看吧。"
|
||||
followUsers: "为了建立属于你自己的时间线,试着去关注你感兴趣的用户吧。"
|
||||
pushNotificationDescription: "启用推送通知的话,就可以在设备上受到来自{name}的通知了。"
|
||||
initialAccountSettingCompleted: "初始设定已经完成了!"
|
||||
haveFun: "希望{name}在这里玩得开心!"
|
||||
ifYouNeedLearnMore: "关于{name}(Misskey)的使用方法,详见{link}。"
|
||||
skipAreYouSure: "要跳过初始设置吗?"
|
||||
_serverRules:
|
||||
description: "在新用户注册前显示服务器的简单规则。推荐显示服务条款的主要内容。"
|
||||
_accountMigration:
|
||||
moveFrom: "从别的账号迁移到此账户"
|
||||
moveFromSub: "为另一个账户建立别名"
|
||||
moveFromLabel: "迁移前的账户"
|
||||
moveFromDescription: "如果迁移时需要继承其他账户的关注者,请在此创造别名。此操作需要在实行迁移之前完成!请如已下输入需要迁移的账户:@person@instance.com"
|
||||
moveTo: "把这个账户迁移到新的账户"
|
||||
moveToLabel: "迁移后的账户"
|
||||
moveCannotBeUndone: "一旦迁移账户,就无法撤销。"
|
||||
moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com"
|
||||
moveAccountHowTo: "要进行账户迁移,请现在目标账户中为此账户建立一个别名。\n建立别名后,请像这样输入目标账户:@username@server.example.com"
|
||||
startMigration: "迁移"
|
||||
migrationConfirm: "确定要把此账户迁移到{account}吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。"
|
||||
movedAndCannotBeUndone: "该账户已被迁移。\n迁移操作无法撤销。"
|
||||
postMigrationNote: "这个账户的关注会在迁移操作后的24小时后解除。该账户的「关注中」和「关注者」皆会变为0。由于不会解除关注关系,你的关注者仍然可以继续查看该账户发补给关注者的帖子。"
|
||||
movedTo: "迁移后的账户"
|
||||
_achievements:
|
||||
earnedAt: "达成时间"
|
||||
|
@ -1331,6 +1354,7 @@ _role:
|
|||
canInvite: "发放服务器邀请码"
|
||||
canManageCustomEmojis: "管理自定义表情符号"
|
||||
driveCapacity: "网盘容量"
|
||||
alwaysMarkNsfw: "总是将文件标记为NSFW"
|
||||
pinMax: "帖子置顶数量限制"
|
||||
antennaMax: "可创建的最大天线数量"
|
||||
wordMuteMax: "屏蔽词的字数限制"
|
||||
|
@ -1583,8 +1607,15 @@ _time:
|
|||
hour: "小时"
|
||||
day: "日"
|
||||
_timelineTutorial:
|
||||
title: "Misskey的使用方法"
|
||||
step1_1: "这个画面是「时间线」。{name}的投稿会按照帖子的发布时间顺序来显示。"
|
||||
step1_2: "时间线有许多种类,比如在「首页时间线」中展现的是你关注的人的贴文;而在「本地时间线」中展现的是{name}里全部用户的贴文。"
|
||||
step2_1: "那么接下来,试着写一些什么东西来发布吧!你可以通过点击屏幕上的铅笔图标来打开投稿页面。"
|
||||
step2_2: "第一次发布的帖子内容,建议包含自我介绍,以及「开始使用{name}了」。"
|
||||
step3_1: "将想说的话发出去了吗?"
|
||||
step3_2: "太棒了!现在你可以在你的时间线中看到刚刚发布的帖子了。"
|
||||
step4_1: "试着对帖子使用「回应」吧!"
|
||||
step4_2: "在他人的帖子上按下「+」图标,即可选择想要的表情来进行「回应」。"
|
||||
_2fa:
|
||||
alreadyRegistered: "此设备已被注册"
|
||||
registerTOTP: "开始设置认证应用"
|
||||
|
|
|
@ -52,6 +52,8 @@ addToList: "加入至清單"
|
|||
sendMessage: "發送訊息"
|
||||
copyRSS: "複製RSS"
|
||||
copyUsername: "複製使用者名稱"
|
||||
copyUserId: "複製使用者ID"
|
||||
copyNoteId: "複製貼文ID"
|
||||
searchUser: "搜尋使用者"
|
||||
reply: "回覆"
|
||||
loadMore: "載入更多"
|
||||
|
@ -790,6 +792,7 @@ noMaintainerInformationWarning: "尚未設定管理員信息。"
|
|||
noBotProtectionWarning: "尚未設定Bot防護。"
|
||||
configure: "設定"
|
||||
postToGallery: "發佈到相簿"
|
||||
postToHashtag: "以此主題標籤發布"
|
||||
gallery: "相簿"
|
||||
recentPosts: "最新貼文"
|
||||
popularPosts: "熱門的貼文"
|
||||
|
@ -823,6 +826,7 @@ translatedFrom: "從 {x} 翻譯"
|
|||
accountDeletionInProgress: "正在刪除帳戶"
|
||||
usernameInfo: "在伺服器上您的帳戶是唯一的識別名稱。您可以使用字母 (a ~ z, A ~ Z)、數字 (0 ~ 9) 和下底線 (_)。之後帳戶名是不能更改的。"
|
||||
aiChanMode: "小藍模式"
|
||||
devMode: "開發者模式"
|
||||
keepCw: "保持CW"
|
||||
pubSub: "Pub/Sub 帳戶"
|
||||
lastCommunication: "最近的通信"
|
||||
|
@ -832,6 +836,8 @@ breakFollow: "解除追隨者"
|
|||
breakFollowConfirm: "確定要取消被追隨嗎?"
|
||||
itsOn: "已開啟"
|
||||
itsOff: "已關閉"
|
||||
on: "開啟"
|
||||
off: "關閉"
|
||||
emailRequiredForSignup: "註冊帳戶需要電子郵件地址"
|
||||
unread: "未讀"
|
||||
filter: "篩選"
|
||||
|
@ -986,6 +992,8 @@ cannotBeChangedLater: "之後不能變更。"
|
|||
reactionAcceptance: "接受表情反應"
|
||||
likeOnly: "僅限讚"
|
||||
likeOnlyForRemote: "遠端僅限讚"
|
||||
nonSensitiveOnly: "僅限非敏感"
|
||||
nonSensitiveOnlyForLocalLikeOnlyForRemote: "僅限非敏感(遠端僅限按讚)"
|
||||
rolesAssignedToMe: "指派給自己的角色"
|
||||
resetPasswordConfirm: "重設密碼?"
|
||||
sensitiveWords: "敏感詞"
|
||||
|
@ -1039,14 +1047,20 @@ thisChannelArchived: "這個頻道已被封存。"
|
|||
displayOfNote: "顯示貼文"
|
||||
initialAccountSetting: "初始設定"
|
||||
youFollowing: "追隨中"
|
||||
preventAiLearning: "拒絕接受產生式AI的學習"
|
||||
preventAiLearningDescription: "要求外部的文章產生AI或圖像產生AI不以發布的貼文和圖像等內容為學習對象。這是透過在HTML響應中包含noai旗標來實現的,但不能完全防止AI的學習,因為這要看該AI是否遵守這個要求。"
|
||||
preventAiLearning: "拒絕接受生成式AI的訓練"
|
||||
preventAiLearningDescription: "要求外部的文章生成式AI或圖像生成式AI不以發布的貼文和圖像等內容為學習對象。這是透過在HTML響應中包含noai旗標來實現的,但不能完全防止AI的學習,因為這要看該AI是否遵守這個要求。"
|
||||
options: "選項"
|
||||
specifyUser: "指定使用者"
|
||||
failedToPreviewUrl: "無法預覽"
|
||||
update: "更新"
|
||||
rolesThatCanBeUsedThisEmojiAsReaction: "可以當成反應使用的角色"
|
||||
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "如果是未指定角色的情況,則任何人都可以被當成反應來使用。"
|
||||
_initialAccountSetting:
|
||||
accountCreated: "帳戶已建立完成!"
|
||||
letsStartAccountSetup: "來進行帳戶的初始設定吧。"
|
||||
letsFillYourProfile: "首先,來設定您的個人檔案吧。"
|
||||
profileSetting: "個人檔案設定"
|
||||
privacySetting: "隱私設定"
|
||||
theseSettingsCanEditLater: "這裡的設定可以在之後變更。"
|
||||
youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。"
|
||||
followUsers: "為了構築時間軸,試著追蹤您感興趣的使用者吧。"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "13.13.0-beta.3",
|
||||
"version": "13.13.0-beta.4",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"@swc/core-win32-x64-msvc": "1.3.56",
|
||||
"@tensorflow/tfjs": "4.4.0",
|
||||
"@tensorflow/tfjs-node": "4.4.0",
|
||||
"bufferutil": "^4.0.7",
|
||||
"slacc-android-arm-eabi": "0.0.9",
|
||||
"slacc-android-arm64": "0.0.9",
|
||||
"slacc-darwin-arm64": "0.0.9",
|
||||
|
@ -46,7 +47,8 @@
|
|||
"slacc-linux-arm64-musl": "0.0.9",
|
||||
"slacc-linux-x64-gnu": "0.0.9",
|
||||
"slacc-win32-arm64-msvc": "0.0.9",
|
||||
"slacc-win32-x64-msvc": "0.0.9"
|
||||
"slacc-win32-x64-msvc": "0.0.9",
|
||||
"utf-8-validate": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.321.1",
|
||||
|
@ -76,7 +78,7 @@
|
|||
"autwh": "0.1.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bull": "4.10.4",
|
||||
"bullmq": "3.14.1",
|
||||
"cacheable-lookup": "6.1.0",
|
||||
"cbor": "8.1.0",
|
||||
"chalk": "5.2.0",
|
||||
|
@ -157,7 +159,6 @@
|
|||
"uuid": "9.0.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.1",
|
||||
"websocket": "1.0.34",
|
||||
"ws": "8.13.0",
|
||||
"xev": "3.0.2"
|
||||
},
|
||||
|
@ -167,7 +168,6 @@
|
|||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.2",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/bull": "4.10.0",
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/color-convert": "2.0.0",
|
||||
"@types/content-disposition": "0.5.5",
|
||||
|
|
|
@ -100,7 +100,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
public async dispose(): Promise<void> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// XXX:
|
||||
// Shutting down the existing connections causes errors on Jest as
|
||||
|
@ -116,4 +116,8 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||
this.redisForSub.disconnect(),
|
||||
]);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -190,6 +190,6 @@ function tryCreateUrl(url: string) {
|
|||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
throw `url="${url}" is not a valid URL.`;
|
||||
throw new Error(`url="${url}" is not a valid URL.`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,11 +55,6 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
this.redisForSub.on('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisForSub.off('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onRedisMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
@ -196,4 +191,14 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
return this.antennas;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -166,7 +166,12 @@ export class CacheService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ export class CaptchaService {
|
|||
}, { throwErrorWhenResponseNotOk: false });
|
||||
|
||||
if (!res.ok) {
|
||||
throw `${res.status}`;
|
||||
throw new Error(`${res.status}`);
|
||||
}
|
||||
|
||||
return await res.json() as CaptchaResponse;
|
||||
|
@ -39,48 +39,48 @@ export class CaptchaService {
|
|||
@bindThis
|
||||
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw 'recaptcha-failed: no response provided';
|
||||
throw new Error('recaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
|
||||
throw `recaptcha-request-failed: ${err}`;
|
||||
throw new Error(`recaptcha-request-failed: ${err}`);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `recaptcha-failed: ${errorCodes}`;
|
||||
throw new Error(`recaptcha-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw 'hcaptcha-failed: no response provided';
|
||||
throw new Error('hcaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
|
||||
throw `hcaptcha-request-failed: ${err}`;
|
||||
throw new Error(`hcaptcha-request-failed: ${err}`);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `hcaptcha-failed: ${errorCodes}`;
|
||||
throw new Error(`hcaptcha-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw 'turnstile-failed: no response provided';
|
||||
throw new Error('turnstile-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
|
||||
throw `turnstile-request-failed: ${err}`;
|
||||
throw new Error(`turnstile-request-failed: ${err}`);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `turnstile-failed: ${errorCodes}`;
|
||||
throw new Error(`turnstile-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,14 +116,14 @@ export class FetchInstanceMetadataService {
|
|||
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
|
||||
.catch(err => {
|
||||
if (err.statusCode === 404) {
|
||||
throw 'No nodeinfo provided';
|
||||
throw new Error('No nodeinfo provided');
|
||||
} else {
|
||||
throw err.statusCode ?? err.message;
|
||||
}
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
|
||||
throw 'No wellknown links';
|
||||
throw new Error('No wellknown links');
|
||||
}
|
||||
|
||||
const links = wellknown.links as any[];
|
||||
|
@ -134,7 +134,7 @@ export class FetchInstanceMetadataService {
|
|||
const link = lnik2_1 ?? lnik2_0 ?? lnik1_0;
|
||||
|
||||
if (link == null) {
|
||||
throw 'No nodeinfo link provided';
|
||||
throw new Error('No nodeinfo link provided');
|
||||
}
|
||||
|
||||
const info = await this.httpRequestService.getJson(link.href)
|
||||
|
|
|
@ -120,8 +120,13 @@ export class MetaService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -510,7 +510,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
this.queueService.endedPollNotificationQueue.add({
|
||||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||
noteId: note.id,
|
||||
}, {
|
||||
delay,
|
||||
|
@ -790,7 +790,13 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
return mentionedUsers;
|
||||
}
|
||||
|
||||
onApplicationShutdown(signal?: string | undefined) {
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,7 +122,13 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
onApplicationShutdown(signal?: string | undefined): void {
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -152,7 +152,13 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
*/
|
||||
}
|
||||
|
||||
onApplicationShutdown(signal?: string | undefined): void {
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,42 +1,11 @@
|
|||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
|
||||
import Bull from 'bull';
|
||||
import * as Bull from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { QUEUE, baseQueueOptions } from '@/queue/const.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
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, {
|
||||
redis: {
|
||||
port: config.redisForJobQueue.port,
|
||||
host: config.redisForJobQueue.host,
|
||||
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
|
||||
password: config.redisForJobQueue.pass,
|
||||
db: config.redisForJobQueue.db ?? 0,
|
||||
},
|
||||
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue',
|
||||
limiter: limitPerSec > 0 ? {
|
||||
max: limitPerSec,
|
||||
duration: 1000,
|
||||
} : undefined,
|
||||
settings: {
|
||||
backoffStrategies: {
|
||||
apBackoff,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
|
||||
function apBackoff(attemptsMade: number, err: Error) {
|
||||
const baseDelay = 60 * 1000; // 1min
|
||||
const maxBackoff = 8 * 60 * 60 * 1000; // 8hours
|
||||
let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay;
|
||||
backoff = Math.min(backoff, maxBackoff);
|
||||
backoff += Math.round(backoff * Math.random() * 0.2);
|
||||
return backoff;
|
||||
}
|
||||
import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js';
|
||||
|
||||
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
|
||||
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
|
||||
|
@ -49,49 +18,49 @@ export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
|
|||
|
||||
const $system: Provider = {
|
||||
provide: 'queue:system',
|
||||
useFactory: (config: Config) => q(config, 'system'),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM, baseQueueOptions(config, QUEUE.SYSTEM)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $endedPollNotification: Provider = {
|
||||
provide: 'queue:endedPollNotification',
|
||||
useFactory: (config: Config) => q(config, 'endedPollNotification'),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.ENDED_POLL_NOTIFICATION, baseQueueOptions(config, QUEUE.ENDED_POLL_NOTIFICATION)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $deliver: Provider = {
|
||||
provide: 'queue:deliver',
|
||||
useFactory: (config: Config) => q(config, 'deliver', config.deliverJobPerSec ?? 128),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $inbox: Provider = {
|
||||
provide: 'queue:inbox',
|
||||
useFactory: (config: Config) => q(config, 'inbox', config.inboxJobPerSec ?? 16),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.INBOX, baseQueueOptions(config, QUEUE.INBOX)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $db: Provider = {
|
||||
provide: 'queue:db',
|
||||
useFactory: (config: Config) => q(config, 'db'),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.DB, baseQueueOptions(config, QUEUE.DB)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $relationship: Provider = {
|
||||
provide: 'queue:relationship',
|
||||
useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.RELATIONSHIP, baseQueueOptions(config, QUEUE.RELATIONSHIP)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $objectStorage: Provider = {
|
||||
provide: 'queue:objectStorage',
|
||||
useFactory: (config: Config) => q(config, 'objectStorage'),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.OBJECT_STORAGE, baseQueueOptions(config, QUEUE.OBJECT_STORAGE)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $webhookDeliver: Provider = {
|
||||
provide: 'queue:webhookDeliver',
|
||||
useFactory: (config: Config) => q(config, 'webhookDeliver', 64),
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
|
@ -131,7 +100,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
public async dispose(): Promise<void> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// XXX:
|
||||
// Shutting down the existing connections causes errors on Jest as
|
||||
|
@ -151,4 +120,8 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
this.webhookDeliverQueue.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import Bull from 'bull';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
|
||||
|
@ -11,6 +10,7 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.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 type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
|
@ -26,7 +26,43 @@ export class QueueService {
|
|||
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
||||
) {}
|
||||
) {
|
||||
this.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '55 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('resyncCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('cleanCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('aggregateRetention', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('clean', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkExpiredMutings', {
|
||||
}, {
|
||||
repeat: { pattern: '*/5 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) {
|
||||
|
@ -42,11 +78,10 @@ export class QueueService {
|
|||
isSharedInbox,
|
||||
};
|
||||
|
||||
return this.deliverQueue.add(data, {
|
||||
return this.deliverQueue.add(to, data, {
|
||||
attempts: this.config.deliverJobMaxAttempts ?? 12,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
|
@ -60,11 +95,10 @@ export class QueueService {
|
|||
signature,
|
||||
};
|
||||
|
||||
return this.inboxQueue.add(data, {
|
||||
return this.inboxQueue.add('', data, {
|
||||
attempts: this.config.inboxJobMaxAttempts ?? 8,
|
||||
timeout: 5 * 60 * 1000, // 5min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
|
@ -212,7 +246,7 @@ export class QueueService {
|
|||
private generateToDbJobData<T extends 'importFollowingToDb' | 'importBlockingToDb', D extends DbJobData<T>>(name: T, data: D): {
|
||||
name: string,
|
||||
data: D,
|
||||
opts: Bull.JobOptions,
|
||||
opts: Bull.JobsOptions,
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
|
@ -299,10 +333,10 @@ export class QueueService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): {
|
||||
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobsOptions = {}): {
|
||||
name: string,
|
||||
data: RelationshipJobData,
|
||||
opts: Bull.JobOptions,
|
||||
opts: Bull.JobsOptions,
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
|
@ -351,11 +385,10 @@ export class QueueService {
|
|||
eventId: uuid(),
|
||||
};
|
||||
|
||||
return this.webhookDeliverQueue.add(data, {
|
||||
return this.webhookDeliverQueue.add(webhook.id, data, {
|
||||
attempts: 4,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
|
@ -367,11 +400,11 @@ export class QueueService {
|
|||
this.deliverQueue.once('cleaned', (jobs, status) => {
|
||||
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.deliverQueue.clean(0, 'delayed');
|
||||
this.deliverQueue.clean(0, Infinity, 'delayed');
|
||||
|
||||
this.inboxQueue.once('cleaned', (jobs, status) => {
|
||||
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.inboxQueue.clean(0, 'delayed');
|
||||
this.inboxQueue.clean(0, Infinity, 'delayed');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -433,7 +433,12 @@ export class RoleService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,7 +81,12 @@ export class WebhookService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,7 +94,7 @@ class LdSignature {
|
|||
@bindThis
|
||||
private getLoader() {
|
||||
return async (url: string): Promise<any> => {
|
||||
if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`;
|
||||
if (!url.match('^https?\:\/\/')) throw new Error(`Invalid URL ${url}`);
|
||||
|
||||
if (this.preLoad) {
|
||||
if (url in CONTEXTS) {
|
||||
|
@ -126,7 +126,7 @@ class LdSignature {
|
|||
timeout: this.loderTimeout,
|
||||
}, { throwErrorWhenResponseNotOk: false }).then(res => {
|
||||
if (!res.ok) {
|
||||
throw `${res.status} ${res.statusText}`;
|
||||
throw new Error(`${res.status} ${res.statusText}`);
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { PollService } from '@/core/PollService.js';
|
|||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
|
@ -32,7 +33,6 @@ import { ApQuestionService } from './ApQuestionService.js';
|
|||
import { ApImageService } from './ApImageService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IPost } from '../type.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApNoteService {
|
||||
|
@ -230,7 +230,7 @@ export class ApNoteService {
|
|||
quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
|
||||
if (!quote) {
|
||||
if (results.some(x => x.status === 'temperror')) {
|
||||
throw 'quote resolve failed';
|
||||
throw new Error('quote resolve failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -311,7 +311,7 @@ export class ApNoteService {
|
|||
|
||||
// ブロックしてたら中断
|
||||
const meta = await this.metaService.fetch();
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw { statusCode: 451 };
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451);
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
|
|
|
@ -60,7 +60,8 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
}, 1000 * 60 * 20);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
@bindThis
|
||||
public async dispose(): Promise<void> {
|
||||
clearInterval(this.saveIntervalId);
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
await Promise.all(
|
||||
|
@ -68,4 +69,9 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,12 @@ export class JanitorService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Xev from 'xev';
|
||||
import * as Bull from 'bullmq';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { QUEUE, baseQueueOptions } from '@/queue/const.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const ev = new Xev();
|
||||
|
@ -13,6 +17,9 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
private intervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
@ -31,11 +38,14 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
let activeDeliverJobs = 0;
|
||||
let activeInboxJobs = 0;
|
||||
|
||||
this.queueService.deliverQueue.on('global:active', () => {
|
||||
const deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
|
||||
const inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
|
||||
|
||||
deliverQueueEvents.on('active', () => {
|
||||
activeDeliverJobs++;
|
||||
});
|
||||
|
||||
this.queueService.inboxQueue.on('global:active', () => {
|
||||
inboxQueueEvents.on('active', () => {
|
||||
activeInboxJobs++;
|
||||
});
|
||||
|
||||
|
@ -71,9 +81,14 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
|
||||
this.intervalId = setInterval(tick, interval);
|
||||
}
|
||||
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,9 +63,14 @@ export class ServerStatsService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// CPU STAT
|
||||
|
|
|
@ -21,7 +21,7 @@ function getNoise(): string {
|
|||
|
||||
export function genAid(date: Date): string {
|
||||
const t = date.getTime();
|
||||
if (isNaN(t)) throw 'Failed to create AID: Invalid Date';
|
||||
if (isNaN(t)) throw new Error('Failed to create AID: Invalid Date');
|
||||
counter++;
|
||||
return getTime(t) + getNoise();
|
||||
}
|
||||
|
|
|
@ -5,15 +5,16 @@ const dateTimeIntervals = {
|
|||
};
|
||||
|
||||
export function dateUTC(time: number[]): Date {
|
||||
const d = time.length === 2 ? Date.UTC(time[0], time[1])
|
||||
: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
|
||||
: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
|
||||
: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
|
||||
: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
|
||||
: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
|
||||
: null;
|
||||
const d =
|
||||
time.length === 2 ? Date.UTC(time[0], time[1])
|
||||
: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
|
||||
: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
|
||||
: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
|
||||
: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
|
||||
: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
|
||||
: null;
|
||||
|
||||
if (!d) throw 'wrong number of arguments';
|
||||
if (!d) throw new Error('wrong number of arguments');
|
||||
|
||||
return new Date(d);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Bull from 'bullmq';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { getJobInfo } from './get-job-info.js';
|
||||
import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
|
@ -35,17 +34,51 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
|
|||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QUEUE, baseQueueOptions } from './const.js';
|
||||
|
||||
// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019
|
||||
function httpRelatedBackoff(attemptsMade: number) {
|
||||
const baseDelay = 60 * 1000; // 1min
|
||||
const maxBackoff = 8 * 60 * 60 * 1000; // 8hours
|
||||
let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay;
|
||||
backoff = Math.min(backoff, maxBackoff);
|
||||
backoff += Math.round(backoff * Math.random() * 0.2);
|
||||
return backoff;
|
||||
}
|
||||
|
||||
function getJobInfo(job: Bull.Job | undefined, increment = false): string {
|
||||
if (job == null) return '-';
|
||||
|
||||
const age = Date.now() - job.timestamp;
|
||||
|
||||
const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m`
|
||||
: age > 10000 ? `${Math.floor(age / 1000)}s`
|
||||
: `${age}ms`;
|
||||
|
||||
// onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
|
||||
const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
|
||||
const maxAttempts = job.opts ? job.opts.attempts : 0;
|
||||
|
||||
return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QueueProcessorService {
|
||||
export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private logger: Logger;
|
||||
private systemQueueWorker: Bull.Worker;
|
||||
private dbQueueWorker: Bull.Worker;
|
||||
private deliverQueueWorker: Bull.Worker;
|
||||
private inboxQueueWorker: Bull.Worker;
|
||||
private webhookDeliverQueueWorker: Bull.Worker;
|
||||
private relationshipQueueWorker: Bull.Worker;
|
||||
private objectStorageQueueWorker: Bull.Worker;
|
||||
private endedPollNotificationQueueWorker: Bull.Worker;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private queueService: QueueService,
|
||||
private webhookDeliverProcessorService: WebhookDeliverProcessorService,
|
||||
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
|
||||
private deliverProcessorService: DeliverProcessorService,
|
||||
|
@ -77,10 +110,7 @@ export class QueueProcessorService {
|
|||
private cleanProcessorService: CleanProcessorService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public start() {
|
||||
function renderError(e: Error): any {
|
||||
if (e) { // 何故かeがundefinedで来ることがある
|
||||
return {
|
||||
|
@ -97,146 +127,232 @@ 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');
|
||||
//#region system
|
||||
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
|
||||
switch (job.name) {
|
||||
case 'tickCharts': return this.tickChartsProcessorService.process();
|
||||
case 'resyncCharts': return this.resyncChartsProcessorService.process();
|
||||
case 'cleanCharts': return this.cleanChartsProcessorService.process();
|
||||
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
|
||||
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
|
||||
case 'clean': return this.cleanProcessorService.process();
|
||||
default: throw new Error(`unrecognized job type ${job.name} for system`);
|
||||
}
|
||||
}, {
|
||||
...baseQueueOptions(this.config, QUEUE.SYSTEM),
|
||||
autorun: false,
|
||||
});
|
||||
|
||||
this.queueService.systemQueue
|
||||
.on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`))
|
||||
const systemLogger = this.logger.createSubLogger('system');
|
||||
|
||||
this.systemQueueWorker
|
||||
.on('active', (job) => systemLogger.debug(`active id=${job.id}`))
|
||||
.on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`))
|
||||
.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}`));
|
||||
.on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
|
||||
.on('error', (err: Error) => systemLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
this.queueService.deliverQueue
|
||||
.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.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}`));
|
||||
//#region db
|
||||
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
|
||||
switch (job.name) {
|
||||
case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job);
|
||||
case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job);
|
||||
case 'exportNotes': return this.exportNotesProcessorService.process(job);
|
||||
case 'exportFavorites': return this.exportFavoritesProcessorService.process(job);
|
||||
case 'exportFollowing': return this.exportFollowingProcessorService.process(job);
|
||||
case 'exportMuting': return this.exportMutingProcessorService.process(job);
|
||||
case 'exportBlocking': return this.exportBlockingProcessorService.process(job);
|
||||
case 'exportUserLists': return this.exportUserListsProcessorService.process(job);
|
||||
case 'exportAntennas': return this.exportAntennasProcessorService.process(job);
|
||||
case 'importFollowing': return this.importFollowingProcessorService.process(job);
|
||||
case 'importFollowingToDb': return this.importFollowingProcessorService.processDb(job);
|
||||
case 'importMuting': return this.importMutingProcessorService.process(job);
|
||||
case 'importBlocking': return this.importBlockingProcessorService.process(job);
|
||||
case 'importBlockingToDb': return this.importBlockingProcessorService.processDb(job);
|
||||
case 'importUserLists': return this.importUserListsProcessorService.process(job);
|
||||
case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job);
|
||||
case 'importAntennas': return this.importAntennasProcessorService.process(job);
|
||||
case 'deleteAccount': return this.deleteAccountProcessorService.process(job);
|
||||
default: throw new Error(`unrecognized job type ${job.name} for db`);
|
||||
}
|
||||
}, {
|
||||
...baseQueueOptions(this.config, QUEUE.DB),
|
||||
autorun: false,
|
||||
});
|
||||
|
||||
this.queueService.inboxQueue
|
||||
.on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`))
|
||||
.on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
|
||||
.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'}`));
|
||||
const dbLogger = this.logger.createSubLogger('db');
|
||||
|
||||
this.queueService.dbQueue
|
||||
.on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`))
|
||||
this.dbQueueWorker
|
||||
.on('active', (job) => dbLogger.debug(`active id=${job.id}`))
|
||||
.on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`))
|
||||
.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}`));
|
||||
.on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
|
||||
.on('error', (err: Error) => dbLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
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}`));
|
||||
//#region deliver
|
||||
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => this.deliverProcessorService.process(job), {
|
||||
...baseQueueOptions(this.config, QUEUE.DELIVER),
|
||||
autorun: false,
|
||||
concurrency: this.config.deliverJobConcurrency ?? 128,
|
||||
limiter: {
|
||||
max: this.config.deliverJobPerSec ?? 128,
|
||||
duration: 1000,
|
||||
},
|
||||
settings: {
|
||||
backoffStrategy: httpRelatedBackoff,
|
||||
},
|
||||
});
|
||||
|
||||
this.queueService.objectStorageQueue
|
||||
.on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`))
|
||||
.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
|
||||
.on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`))
|
||||
.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}`));
|
||||
const deliverLogger = this.logger.createSubLogger('deliver');
|
||||
|
||||
this.queueService.webhookDeliverQueue
|
||||
.on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`))
|
||||
this.deliverQueueWorker
|
||||
.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`))
|
||||
.on('error', (err: Error) => deliverLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
//#region inbox
|
||||
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => this.inboxProcessorService.process(job), {
|
||||
...baseQueueOptions(this.config, QUEUE.INBOX),
|
||||
autorun: false,
|
||||
concurrency: this.config.inboxJobConcurrency ?? 16,
|
||||
limiter: {
|
||||
max: this.config.inboxJobPerSec ?? 16,
|
||||
duration: 1000,
|
||||
},
|
||||
settings: {
|
||||
backoffStrategy: httpRelatedBackoff,
|
||||
},
|
||||
});
|
||||
|
||||
const inboxLogger = this.logger.createSubLogger('inbox');
|
||||
|
||||
this.inboxQueueWorker
|
||||
.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`))
|
||||
.on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
|
||||
.on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }))
|
||||
.on('error', (err: Error) => inboxLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
//#region webhook deliver
|
||||
this.webhookDeliverQueueWorker = new Bull.Worker(QUEUE.WEBHOOK_DELIVER, (job) => this.webhookDeliverProcessorService.process(job), {
|
||||
...baseQueueOptions(this.config, QUEUE.WEBHOOK_DELIVER),
|
||||
autorun: false,
|
||||
concurrency: 64,
|
||||
limiter: {
|
||||
max: 64,
|
||||
duration: 1000,
|
||||
},
|
||||
settings: {
|
||||
backoffStrategy: httpRelatedBackoff,
|
||||
},
|
||||
});
|
||||
|
||||
const webhookLogger = this.logger.createSubLogger('webhook');
|
||||
|
||||
this.webhookDeliverQueueWorker
|
||||
.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
|
||||
.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}`));
|
||||
.on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`))
|
||||
.on('error', (err: Error) => webhookLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
this.queueService.systemQueue.add('tickCharts', {
|
||||
//#region relationship
|
||||
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
|
||||
switch (job.name) {
|
||||
case 'follow': return this.relationshipProcessorService.processFollow(job);
|
||||
case 'unfollow': return this.relationshipProcessorService.processUnfollow(job);
|
||||
case 'block': return this.relationshipProcessorService.processBlock(job);
|
||||
case 'unblock': return this.relationshipProcessorService.processUnblock(job);
|
||||
default: throw new Error(`unrecognized job type ${job.name} for relationship`);
|
||||
}
|
||||
}, {
|
||||
repeat: { cron: '55 * * * *' },
|
||||
removeOnComplete: true,
|
||||
...baseQueueOptions(this.config, QUEUE.RELATIONSHIP),
|
||||
autorun: false,
|
||||
concurrency: this.config.relashionshipJobConcurrency ?? 16,
|
||||
limiter: {
|
||||
max: this.config.relashionshipJobPerSec ?? 64,
|
||||
duration: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
this.queueService.systemQueue.add('resyncCharts', {
|
||||
}, {
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.queueService.systemQueue.add('cleanCharts', {
|
||||
}, {
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.queueService.systemQueue.add('aggregateRetention', {
|
||||
}, {
|
||||
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.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.queueService.dbQueue.process('deleteDriveFiles', (job, done) => this.deleteDriveFilesProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportCustomEmojis', (job, done) => this.exportCustomEmojisProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportNotes', (job, done) => this.exportNotesProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportFavorites', (job, done) => this.exportFavoritesProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportFollowing', (job, done) => this.exportFollowingProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
|
||||
this.queueService.dbQueue.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
|
||||
this.queueService.dbQueue.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done));
|
||||
this.queueService.dbQueue.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
|
||||
|
||||
this.queueService.objectStorageQueue.process('deleteFile', 16, (job) => this.deleteFileProcessorService.process(job));
|
||||
this.queueService.objectStorageQueue.process('cleanRemoteFiles', 16, (job, done) => this.cleanRemoteFilesProcessorService.process(job, done));
|
||||
const relationshipLogger = this.logger.createSubLogger('relationship');
|
||||
|
||||
{
|
||||
const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
|
||||
this.queueService.relationshipQueue.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
|
||||
this.queueService.relationshipQueue.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
|
||||
this.queueService.relationshipQueue.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
|
||||
this.queueService.relationshipQueue.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job));
|
||||
}
|
||||
this.relationshipQueueWorker
|
||||
.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 ? job.id : '-'}`, { job, e: renderError(err) }))
|
||||
.on('error', (err: Error) => relationshipLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
this.queueService.systemQueue.process('tickCharts', (job, done) => this.tickChartsProcessorService.process(job, done));
|
||||
this.queueService.systemQueue.process('resyncCharts', (job, done) => this.resyncChartsProcessorService.process(job, done));
|
||||
this.queueService.systemQueue.process('cleanCharts', (job, done) => this.cleanChartsProcessorService.process(job, done));
|
||||
this.queueService.systemQueue.process('aggregateRetention', (job, done) => this.aggregateRetentionProcessorService.process(job, done));
|
||||
this.queueService.systemQueue.process('checkExpiredMutings', (job, done) => this.checkExpiredMutingsProcessorService.process(job, done));
|
||||
this.queueService.systemQueue.process('clean', (job, done) => this.cleanProcessorService.process(job, done));
|
||||
//#region object storage
|
||||
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
|
||||
switch (job.name) {
|
||||
case 'deleteFile': return this.deleteFileProcessorService.process(job);
|
||||
case 'cleanRemoteFiles': return this.cleanRemoteFilesProcessorService.process(job);
|
||||
default: throw new Error(`unrecognized job type ${job.name} for objectStorage`);
|
||||
}
|
||||
}, {
|
||||
...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE),
|
||||
autorun: false,
|
||||
concurrency: 16,
|
||||
});
|
||||
|
||||
const objectStorageLogger = this.logger.createSubLogger('objectStorage');
|
||||
|
||||
this.objectStorageQueueWorker
|
||||
.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
|
||||
.on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`))
|
||||
.on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }))
|
||||
.on('error', (err: Error) => objectStorageLogger.error(`error ${err}`, { e: renderError(err) }))
|
||||
.on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`));
|
||||
//#endregion
|
||||
|
||||
//#region ended poll notification
|
||||
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => this.endedPollNotificationProcessorService.process(job), {
|
||||
...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION),
|
||||
autorun: false,
|
||||
});
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async start(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.systemQueueWorker.run(),
|
||||
this.dbQueueWorker.run(),
|
||||
this.deliverQueueWorker.run(),
|
||||
this.inboxQueueWorker.run(),
|
||||
this.webhookDeliverQueueWorker.run(),
|
||||
this.relationshipQueueWorker.run(),
|
||||
this.objectStorageQueueWorker.run(),
|
||||
this.endedPollNotificationQueueWorker.run(),
|
||||
]);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async stop(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.systemQueueWorker.close(),
|
||||
this.dbQueueWorker.close(),
|
||||
this.deliverQueueWorker.close(),
|
||||
this.inboxQueueWorker.close(),
|
||||
this.webhookDeliverQueueWorker.close(),
|
||||
this.relationshipQueueWorker.close(),
|
||||
this.objectStorageQueueWorker.close(),
|
||||
this.endedPollNotificationQueueWorker.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
}
|
||||
|
|
26
packages/backend/src/queue/const.ts
Normal file
26
packages/backend/src/queue/const.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Config } from '@/config.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
export const QUEUE = {
|
||||
DELIVER: 'deliver',
|
||||
INBOX: 'inbox',
|
||||
SYSTEM: 'system',
|
||||
ENDED_POLL_NOTIFICATION: 'endedPollNotification',
|
||||
DB: 'db',
|
||||
RELATIONSHIP: 'relationship',
|
||||
OBJECT_STORAGE: 'objectStorage',
|
||||
WEBHOOK_DELIVER: 'webhookDeliver',
|
||||
};
|
||||
|
||||
export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
|
||||
return {
|
||||
connection: {
|
||||
port: config.redisForJobQueue.port,
|
||||
host: config.redisForJobQueue.host,
|
||||
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
|
||||
password: config.redisForJobQueue.pass,
|
||||
db: config.redisForJobQueue.db ?? 0,
|
||||
},
|
||||
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue:${queueName}` : `queue:${queueName}`,
|
||||
};
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import Bull from 'bull';
|
||||
|
||||
export function getJobInfo(job: Bull.Job, increment = false) {
|
||||
const age = Date.now() - job.timestamp;
|
||||
|
||||
const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m`
|
||||
: age > 10000 ? `${Math.floor(age / 1000)}s`
|
||||
: `${age}ms`;
|
||||
|
||||
// onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
|
||||
const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
|
||||
const maxAttempts = job.opts ? job.opts.attempts : 0;
|
||||
|
||||
return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
|
||||
}
|
|
@ -9,7 +9,7 @@ import { deepClone } from '@/misc/clone.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class AggregateRetentionProcessorService {
|
||||
|
@ -32,7 +32,7 @@ export class AggregateRetentionProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('Aggregating retention...');
|
||||
|
||||
const now = new Date();
|
||||
|
@ -62,7 +62,6 @@ export class AggregateRetentionProcessorService {
|
|||
} catch (err) {
|
||||
if (isDuplicateKeyValueError(err)) {
|
||||
this.logger.succ('Skip because it has already been processed by another worker.');
|
||||
done();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
|
@ -88,6 +87,5 @@ export class AggregateRetentionProcessorService {
|
|||
}
|
||||
|
||||
this.logger.succ('Retention aggregated.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { UserMutingService } from '@/core/UserMutingService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CheckExpiredMutingsProcessorService {
|
||||
|
@ -27,7 +27,7 @@ export class CheckExpiredMutingsProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('Checking expired mutings...');
|
||||
|
||||
const expired = await this.mutingsRepository.createQueryBuilder('muting')
|
||||
|
@ -41,6 +41,5 @@ export class CheckExpiredMutingsProcessorService {
|
|||
}
|
||||
|
||||
this.logger.succ('All expired mutings checked.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
|||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CleanChartsProcessorService {
|
||||
|
@ -45,7 +45,7 @@ export class CleanChartsProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('Clean charts...');
|
||||
|
||||
await Promise.all([
|
||||
|
@ -64,6 +64,5 @@ export class CleanChartsProcessorService {
|
|||
]);
|
||||
|
||||
this.logger.succ('All charts successfully cleaned.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import type Logger from '@/logger.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CleanProcessorService {
|
||||
|
@ -36,7 +36,7 @@ export class CleanProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('Cleaning...');
|
||||
|
||||
this.userIpsRepository.delete({
|
||||
|
@ -72,6 +72,5 @@ export class CleanProcessorService {
|
|||
}
|
||||
|
||||
this.logger.succ('Cleaned.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@ import type { DriveFilesRepository } from '@/models/index.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CleanRemoteFilesProcessorService {
|
||||
|
@ -27,7 +27,7 @@ export class CleanRemoteFilesProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<Record<string, unknown>>): Promise<void> {
|
||||
this.logger.info('Deleting cached remote files...');
|
||||
|
||||
let deletedCount = 0;
|
||||
|
@ -47,7 +47,7 @@ export class CleanRemoteFilesProcessorService {
|
|||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
job.progress(100);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -62,10 +62,9 @@ export class CleanRemoteFilesProcessorService {
|
|||
isLink: false,
|
||||
});
|
||||
|
||||
job.progress(deletedCount / total);
|
||||
job.updateProgress(deletedCount / total);
|
||||
}
|
||||
|
||||
this.logger.succ('All cached remote files has been deleted.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,10 +8,10 @@ import { DriveService } from '@/core/DriveService.js';
|
|||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserDeleteJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserDeleteJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountProcessorService {
|
||||
|
|
|
@ -5,10 +5,10 @@ import type { UsersRepository, DriveFilesRepository } from '@/models/index.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteDriveFilesProcessorService {
|
||||
|
@ -31,12 +31,11 @@ export class DeleteDriveFilesProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Deleting drive files of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -56,7 +55,7 @@ export class DeleteDriveFilesProcessorService {
|
|||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
job.progress(100);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -71,10 +70,9 @@ export class DeleteDriveFilesProcessorService {
|
|||
userId: user.id,
|
||||
});
|
||||
|
||||
job.progress(deletedCount / total);
|
||||
job.updateProgress(deletedCount / total);
|
||||
}
|
||||
|
||||
this.logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`);
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,10 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { ObjectStorageFileJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { ObjectStorageFileJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteFileProcessorService {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Bull from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, InstancesRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -16,7 +17,6 @@ import { StatusError } from '@/misc/status-error.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DeliverJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -121,15 +121,13 @@ export class DeliverProcessorService {
|
|||
isSuspended: true,
|
||||
});
|
||||
});
|
||||
return `${host} is gone`;
|
||||
throw new Bull.UnrecoverableError(`${host} is gone`);
|
||||
}
|
||||
// HTTPステータスコード4xxはクライアントエラーであり、それはつまり
|
||||
// 何回再送しても成功することはないということなのでエラーにはしないでおく
|
||||
return `${res.statusCode} ${res.statusMessage}`;
|
||||
throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
|
||||
}
|
||||
|
||||
// 5xx etc.
|
||||
throw `${res.statusCode} ${res.statusMessage}`;
|
||||
throw new Error(`${res.statusCode} ${res.statusMessage}`);
|
||||
} else {
|
||||
// DNS error, socket error, timeout ...
|
||||
throw res;
|
||||
|
|
|
@ -6,7 +6,7 @@ import type Logger from '@/logger.js';
|
|||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { EndedPollNotificationJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -30,10 +30,9 @@ export class EndedPollNotificationProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<EndedPollNotificationJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<EndedPollNotificationJobData>): Promise<void> {
|
||||
const note = await this.notesRepository.findOneBy({ id: job.data.noteId });
|
||||
if (note == null || !note.hasPoll) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -51,7 +50,5 @@ export class EndedPollNotificationProcessorService {
|
|||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import { createTemp } from '@/misc/create-temp.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type { DBExportAntennasData } from '../types.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class ExportAntennasProcessorService {
|
||||
|
@ -39,10 +39,9 @@ export class ExportAntennasProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DBExportAntennasData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DBExportAntennasData>): Promise<void> {
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
@ -96,7 +95,6 @@ export class ExportAntennasProcessorService {
|
|||
this.logger.succ('Exported to: ' + driveFile.id);
|
||||
} finally {
|
||||
cleanup();
|
||||
done();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,10 @@ import type Logger from '@/logger.js';
|
|||
import { DriveService } from '@/core/DriveService.js';
|
||||
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 { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportBlockingProcessorService {
|
||||
|
@ -36,12 +36,11 @@ export class ExportBlockingProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Exporting blocking of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -69,7 +68,7 @@ export class ExportBlockingProcessorService {
|
|||
});
|
||||
|
||||
if (blockings.length === 0) {
|
||||
job.progress(100);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -99,7 +98,7 @@ export class ExportBlockingProcessorService {
|
|||
blockerId: user.id,
|
||||
});
|
||||
|
||||
job.progress(exportedCount / total);
|
||||
job.updateProgress(exportedCount / total);
|
||||
}
|
||||
|
||||
stream.end();
|
||||
|
@ -112,7 +111,5 @@ export class ExportBlockingProcessorService {
|
|||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import { createTemp, createTempDir } from '@/misc/create-temp.js';
|
|||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class ExportCustomEmojisProcessorService {
|
||||
|
@ -37,12 +37,11 @@ export class ExportCustomEmojisProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job): Promise<void> {
|
||||
this.logger.info('Exporting custom emojis ...');
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -117,24 +116,26 @@ export class ExportCustomEmojisProcessorService {
|
|||
metaStream.end();
|
||||
|
||||
// Create archive
|
||||
const [archivePath, archiveCleanup] = await createTemp();
|
||||
const archiveStream = fs.createWriteStream(archivePath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 0 },
|
||||
});
|
||||
archiveStream.on('close', async () => {
|
||||
this.logger.succ(`Exported to: ${archivePath}`);
|
||||
await new Promise<void>(async (resolve) => {
|
||||
const [archivePath, archiveCleanup] = await createTemp();
|
||||
const archiveStream = fs.createWriteStream(archivePath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 0 },
|
||||
});
|
||||
archiveStream.on('close', async () => {
|
||||
this.logger.succ(`Exported to: ${archivePath}`);
|
||||
|
||||
const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip';
|
||||
const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true });
|
||||
const fileName = 'custom-emojis-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.zip';
|
||||
const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true });
|
||||
|
||||
this.logger.succ(`Exported to: ${driveFile.id}`);
|
||||
cleanup();
|
||||
archiveCleanup();
|
||||
done();
|
||||
this.logger.succ(`Exported to: ${driveFile.id}`);
|
||||
cleanup();
|
||||
archiveCleanup();
|
||||
resolve();
|
||||
});
|
||||
archive.pipe(archiveStream);
|
||||
archive.directory(path, false);
|
||||
archive.finalize();
|
||||
});
|
||||
archive.pipe(archiveStream);
|
||||
archive.directory(path, false);
|
||||
archive.finalize();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import type { Poll } from '@/models/entities/Poll.js';
|
|||
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 * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -42,12 +42,11 @@ export class ExportFavoritesProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Exporting favorites of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -91,7 +90,7 @@ export class ExportFavoritesProcessorService {
|
|||
}) as (NoteFavorite & { note: Note & { user: User } })[];
|
||||
|
||||
if (favorites.length === 0) {
|
||||
job.progress(100);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -112,7 +111,7 @@ export class ExportFavoritesProcessorService {
|
|||
userId: user.id,
|
||||
});
|
||||
|
||||
job.progress(exportedFavoritesCount / total);
|
||||
job.updateProgress(exportedFavoritesCount / total);
|
||||
}
|
||||
|
||||
await write(']');
|
||||
|
@ -127,8 +126,6 @@ export class ExportFavoritesProcessorService {
|
|||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js';
|
|||
import { createTemp } from '@/misc/create-temp.js';
|
||||
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 { DbExportFollowingData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbExportFollowingData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportFollowingProcessorService {
|
||||
|
@ -40,12 +40,11 @@ export class ExportFollowingProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbExportFollowingData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbExportFollowingData>): Promise<void> {
|
||||
this.logger.info(`Exporting following of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -116,7 +115,5 @@ export class ExportFollowingProcessorService {
|
|||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,10 @@ import type Logger from '@/logger.js';
|
|||
import { DriveService } from '@/core/DriveService.js';
|
||||
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 { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportMutingProcessorService {
|
||||
|
@ -39,12 +39,11 @@ export class ExportMutingProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Exporting muting of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -73,7 +72,7 @@ export class ExportMutingProcessorService {
|
|||
});
|
||||
|
||||
if (mutes.length === 0) {
|
||||
job.progress(100);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -103,7 +102,7 @@ export class ExportMutingProcessorService {
|
|||
muterId: user.id,
|
||||
});
|
||||
|
||||
job.progress(exportedCount / total);
|
||||
job.updateProgress(exportedCount / total);
|
||||
}
|
||||
|
||||
stream.end();
|
||||
|
@ -116,7 +115,5 @@ export class ExportMutingProcessorService {
|
|||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import type { Poll } from '@/models/entities/Poll.js';
|
|||
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 * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -39,12 +39,11 @@ export class ExportNotesProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Exporting notes of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -87,7 +86,7 @@ export class ExportNotesProcessorService {
|
|||
}) as Note[];
|
||||
|
||||
if (notes.length === 0) {
|
||||
job.progress(100);
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -108,7 +107,7 @@ export class ExportNotesProcessorService {
|
|||
userId: user.id,
|
||||
});
|
||||
|
||||
job.progress(exportedNotesCount / total);
|
||||
job.updateProgress(exportedNotesCount / total);
|
||||
}
|
||||
|
||||
await write(']');
|
||||
|
@ -123,8 +122,6 @@ export class ExportNotesProcessorService {
|
|||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,10 +9,10 @@ import type Logger from '@/logger.js';
|
|||
import { DriveService } from '@/core/DriveService.js';
|
||||
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 { DbJobDataWithUser } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ExportUserListsProcessorService {
|
||||
|
@ -39,12 +39,11 @@ export class ExportUserListsProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbJobDataWithUser>): Promise<void> {
|
||||
this.logger.info(`Exporting user lists of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -92,7 +91,5 @@ export class ExportUserListsProcessorService {
|
|||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import { DBAntennaImportJobData } from '../types.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
const validate = new Ajv().compile({
|
||||
type: 'object',
|
||||
|
@ -59,7 +59,7 @@ export class ImportAntennasProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DBAntennaImportJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DBAntennaImportJobData>): Promise<void> {
|
||||
const now = new Date();
|
||||
try {
|
||||
for (const antenna of job.data.antenna) {
|
||||
|
@ -89,8 +89,6 @@ export class ImportAntennasProcessorService {
|
|||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(err);
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,11 +7,11 @@ import * as Acct from '@/misc/acct.js';
|
|||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.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, DbUserImportToDbJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ImportBlockingProcessorService {
|
||||
|
@ -34,12 +34,11 @@ export class ImportBlockingProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
|
||||
this.logger.info(`Importing blocking of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -47,7 +46,6 @@ export class ImportBlockingProcessorService {
|
|||
id: job.data.fileId,
|
||||
});
|
||||
if (file == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -56,7 +54,6 @@ export class ImportBlockingProcessorService {
|
|||
this.queueService.createImportBlockingToDbJob({ id: user.id }, targets);
|
||||
|
||||
this.logger.succ('Import jobs created');
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -85,7 +82,7 @@ export class ImportBlockingProcessorService {
|
|||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `Unable to resolve user: @${username}@${host}`;
|
||||
throw new Error(`Unable to resolve user: @${username}@${host}`);
|
||||
}
|
||||
|
||||
// skip myself
|
||||
|
|
|
@ -12,7 +12,7 @@ import { DriveService } from '@/core/DriveService.js';
|
|||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserImportJobData } from '../types.js';
|
||||
|
||||
// TODO: 名前衝突時の動作を選べるようにする
|
||||
|
@ -45,14 +45,13 @@ export class ImportCustomEmojisProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
|
||||
this.logger.info('Importing custom emojis ...');
|
||||
|
||||
const file = await this.driveFilesRepository.findOneBy({
|
||||
id: job.data.fileId,
|
||||
});
|
||||
if (file == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -116,7 +115,6 @@ export class ImportCustomEmojisProcessorService {
|
|||
cleanup();
|
||||
|
||||
this.logger.succ('Imported');
|
||||
done();
|
||||
});
|
||||
unzipStream.pipe(extractor);
|
||||
this.logger.succ(`Unzipping to ${outputPath}`);
|
||||
|
|
|
@ -7,11 +7,11 @@ import * as Acct from '@/misc/acct.js';
|
|||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.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, DbUserImportToDbJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ImportFollowingProcessorService {
|
||||
|
@ -34,12 +34,11 @@ export class ImportFollowingProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
|
||||
this.logger.info(`Importing following of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -47,7 +46,6 @@ export class ImportFollowingProcessorService {
|
|||
id: job.data.fileId,
|
||||
});
|
||||
if (file == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -56,7 +54,6 @@ export class ImportFollowingProcessorService {
|
|||
this.queueService.createImportFollowingToDbJob({ id: user.id }, targets);
|
||||
|
||||
this.logger.succ('Import jobs created');
|
||||
done();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -85,7 +82,7 @@ export class ImportFollowingProcessorService {
|
|||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `Unable to resolve user: @${username}@${host}`;
|
||||
throw new Error(`Unable to resolve user: @${username}@${host}`);
|
||||
}
|
||||
|
||||
// skip myself
|
||||
|
|
|
@ -9,10 +9,10 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
|||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { UserMutingService } from '@/core/UserMutingService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { DbUserImportJobData } from '../types.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserImportJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ImportMutingProcessorService {
|
||||
|
@ -38,12 +38,11 @@ export class ImportMutingProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
|
||||
this.logger.info(`Importing muting of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -51,7 +50,6 @@ export class ImportMutingProcessorService {
|
|||
id: job.data.fileId,
|
||||
});
|
||||
if (file == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -83,7 +81,7 @@ export class ImportMutingProcessorService {
|
|||
}
|
||||
|
||||
if (target == null) {
|
||||
throw `cannot resolve user: @${username}@${host}`;
|
||||
throw new Error(`cannot resolve user: @${username}@${host}`);
|
||||
}
|
||||
|
||||
// skip myself
|
||||
|
@ -98,6 +96,5 @@ export class ImportMutingProcessorService {
|
|||
}
|
||||
|
||||
this.logger.succ('Imported');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserImportJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -46,12 +46,11 @@ export class ImportUserListsProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<DbUserImportJobData>, done: () => void): Promise<void> {
|
||||
public async process(job: Bull.Job<DbUserImportJobData>): Promise<void> {
|
||||
this.logger.info(`Importing user lists of ${job.data.user.id} ...`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
|
||||
if (user == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -59,7 +58,6 @@ export class ImportUserListsProcessorService {
|
|||
id: job.data.fileId,
|
||||
});
|
||||
if (file == null) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -109,6 +107,5 @@ export class ImportUserListsProcessorService {
|
|||
}
|
||||
|
||||
this.logger.succ('Imported');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import httpSignature from '@peertube/http-signature';
|
||||
import * as Bull from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { InstancesRepository, DriveFilesRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
@ -23,10 +23,8 @@ import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js';
|
|||
import { ApInboxService } from '@/core/activitypub/ApInboxService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { InboxJobData } from '../types.js';
|
||||
|
||||
// ユーザーのinboxにアクティビティが届いた時の処理
|
||||
@Injectable()
|
||||
export class InboxProcessorService {
|
||||
private logger: Logger;
|
||||
|
@ -35,12 +33,6 @@ export class InboxProcessorService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private metaService: MetaService,
|
||||
private apInboxService: ApInboxService,
|
||||
|
@ -93,24 +85,24 @@ export class InboxProcessorService {
|
|||
try {
|
||||
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor));
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
if (err.isClientError) {
|
||||
return `skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`;
|
||||
throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
|
||||
}
|
||||
throw `Error in actor ${activity.actor} - ${err.statusCode ?? err}`;
|
||||
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode ?? err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// それでもわからなければ終了
|
||||
if (authUser == null) {
|
||||
return 'skip: failed to resolve user';
|
||||
throw new Bull.UnrecoverableError('skip: failed to resolve user');
|
||||
}
|
||||
|
||||
// publicKey がなくても終了
|
||||
if (authUser.key == null) {
|
||||
return 'skip: failed to resolve user publicKey';
|
||||
throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey');
|
||||
}
|
||||
|
||||
// HTTP-Signatureの検証
|
||||
|
@ -118,10 +110,10 @@ export class InboxProcessorService {
|
|||
|
||||
// また、signatureのsignerは、activity.actorと一致する必要がある
|
||||
if (!httpSignatureValidated || authUser.user.uri !== activity.actor) {
|
||||
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る
|
||||
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る
|
||||
if (activity.signature) {
|
||||
if (activity.signature.type !== 'RsaSignature2017') {
|
||||
return `skip: unsupported LD-signature type ${activity.signature.type}`;
|
||||
throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${activity.signature.type}`);
|
||||
}
|
||||
|
||||
// activity.signature.creator: https://example.oom/users/user#main-key
|
||||
|
@ -134,32 +126,32 @@ export class InboxProcessorService {
|
|||
// keyIdからLD-Signatureのユーザーを取得
|
||||
authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator);
|
||||
if (authUser == null) {
|
||||
return 'skip: LD-Signatureのユーザーが取得できませんでした';
|
||||
throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした');
|
||||
}
|
||||
|
||||
if (authUser.key == null) {
|
||||
return 'skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした';
|
||||
throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした');
|
||||
}
|
||||
|
||||
// LD-Signature検証
|
||||
const ldSignature = this.ldSignatureService.use();
|
||||
const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false);
|
||||
if (!verified) {
|
||||
return 'skip: LD-Signatureの検証に失敗しました';
|
||||
throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました');
|
||||
}
|
||||
|
||||
// もう一度actorチェック
|
||||
if (authUser.user.uri !== activity.actor) {
|
||||
return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`;
|
||||
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);
|
||||
}
|
||||
|
||||
// ブロックしてたら中断
|
||||
const ldHost = this.utilityService.extractDbHost(authUser.user.uri);
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) {
|
||||
return `Blocked request: ${ldHost}`;
|
||||
throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`);
|
||||
}
|
||||
} else {
|
||||
return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`;
|
||||
throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -168,7 +160,7 @@ export class InboxProcessorService {
|
|||
const signerHost = this.utilityService.extractDbHost(authUser.user.uri!);
|
||||
const activityIdHost = this.utilityService.extractDbHost(activity.id);
|
||||
if (signerHost !== activityIdHost) {
|
||||
return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`;
|
||||
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
|
|
|
@ -15,7 +15,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
|||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class ResyncChartsProcessorService {
|
||||
|
@ -43,7 +43,7 @@ export class ResyncChartsProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('Resync charts...');
|
||||
|
||||
// TODO: ユーザーごとのチャートも更新する
|
||||
|
@ -55,6 +55,5 @@ export class ResyncChartsProcessorService {
|
|||
]);
|
||||
|
||||
this.logger.succ('All charts successfully resynced.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
|||
import ApRequestChart from '@/core/chart/charts/ap-request.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class TickChartsProcessorService {
|
||||
|
@ -45,7 +45,7 @@ export class TickChartsProcessorService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>, done: () => void): Promise<void> {
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('Tick charts...');
|
||||
|
||||
await Promise.all([
|
||||
|
@ -64,6 +64,5 @@ export class TickChartsProcessorService {
|
|||
]);
|
||||
|
||||
this.logger.succ('All charts successfully ticked.');
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Bull from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { WebhooksRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -7,7 +8,6 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
|||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type Bull from 'bull';
|
||||
import type { WebhookDeliverJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -66,11 +66,11 @@ export class WebhookDeliverProcessorService {
|
|||
if (res instanceof StatusError) {
|
||||
// 4xx
|
||||
if (res.isClientError) {
|
||||
return `${res.statusCode} ${res.statusMessage}`;
|
||||
throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`);
|
||||
}
|
||||
|
||||
// 5xx etc.
|
||||
throw `${res.statusCode} ${res.statusMessage}`;
|
||||
throw new Error(`${res.statusCode} ${res.statusMessage}`);
|
||||
} else {
|
||||
// DNS error, socket error, timeout ...
|
||||
throw res;
|
||||
|
|
|
@ -194,7 +194,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
|
||||
fastify.register(this.clientServerService.createServer);
|
||||
|
||||
this.streamingApiServerService.attachStreamingApi(fastify.server);
|
||||
this.streamingApiServerService.attach(fastify.server);
|
||||
|
||||
fastify.server.on('error', err => {
|
||||
switch ((err as any).code) {
|
||||
|
@ -222,7 +222,14 @@ export class ServerService implements OnApplicationShutdown {
|
|||
await fastify.ready();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
@bindThis
|
||||
public async dispose(): Promise<void> {
|
||||
await this.streamingApiServerService.detach();
|
||||
await this.#fastify.close();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -363,7 +363,12 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
public dispose(): void {
|
||||
clearInterval(this.userIpHistoriesClearIntervalId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ export class AuthenticateService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async authenticate(token: string | null | undefined): Promise<[LocalUser | null | undefined, AccessToken | null | undefined]> {
|
||||
public async authenticate(token: string | null | undefined): Promise<[LocalUser | null, AccessToken | null]> {
|
||||
if (token == null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as websocket from 'websocket';
|
||||
import * as WebSocket from 'ws';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js';
|
||||
import type { UsersRepository, AccessToken } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { AuthenticateService } from './AuthenticateService.js';
|
||||
import { LocalUser } from '@/models/entities/User';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/index.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
import type { ParsedUrlQuery } from 'querystring';
|
||||
import type * as http from 'node:http';
|
||||
|
||||
@Injectable()
|
||||
export class StreamingApiServerService {
|
||||
#wss: WebSocket.WebSocketServer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
@ -28,24 +30,6 @@ export class StreamingApiServerService {
|
|||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private noteReadService: NoteReadService,
|
||||
private authenticateService: AuthenticateService,
|
||||
|
@ -55,25 +39,65 @@ export class StreamingApiServerService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public attachStreamingApi(server: http.Server) {
|
||||
// Init websocket server
|
||||
const ws = new websocket.server({
|
||||
httpServer: server,
|
||||
public attach(server: http.Server): void {
|
||||
this.#wss = new WebSocket.WebSocketServer({
|
||||
noServer: true,
|
||||
});
|
||||
|
||||
ws.on('request', async (request) => {
|
||||
const q = request.resourceURL.query as ParsedUrlQuery;
|
||||
|
||||
// TODO: トークンが間違ってるなどしてauthenticateに失敗したら
|
||||
// コネクション切断するなりエラーメッセージ返すなりする
|
||||
// (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので)
|
||||
const [user, miapp] = await this.authenticateService.authenticate(q.i as string);
|
||||
|
||||
if (user?.isSuspended) {
|
||||
request.reject(400);
|
||||
server.on('upgrade', async (request, socket, head) => {
|
||||
if (request.url == null) {
|
||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const q = new URL(request.url, `http://${request.headers.host}`).searchParams;
|
||||
|
||||
let user: LocalUser | null = null;
|
||||
let app: AccessToken | null = null;
|
||||
|
||||
try {
|
||||
[user, app] = await this.authenticateService.authenticate(q.get('i'));
|
||||
} catch (e) {
|
||||
if (e instanceof AuthenticationError) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
} else {
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (user?.isSuspended) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = new MainStreamConnection(
|
||||
this.channelsService,
|
||||
this.noteReadService,
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
user, app,
|
||||
);
|
||||
|
||||
await stream.init();
|
||||
|
||||
this.#wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.#wss.emit('connection', ws, request, {
|
||||
stream, user, app,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: {
|
||||
stream: MainStreamConnection,
|
||||
user: LocalUser | null;
|
||||
app: AccessToken | null
|
||||
}) => {
|
||||
const { stream, user, app } = ctx;
|
||||
|
||||
const ev = new EventEmitter();
|
||||
|
||||
async function onRedisMessage(_: string, data: string): Promise<void> {
|
||||
|
@ -83,19 +107,7 @@ export class StreamingApiServerService {
|
|||
|
||||
this.redisForSub.on('message', onRedisMessage);
|
||||
|
||||
const main = new MainStreamConnection(
|
||||
this.channelsService,
|
||||
this.noteReadService,
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
ev, user, miapp,
|
||||
);
|
||||
|
||||
await main.init();
|
||||
|
||||
const connection = request.accept();
|
||||
|
||||
main.init2(connection);
|
||||
await stream.listen(ev, connection);
|
||||
|
||||
const intervalId = user ? setInterval(() => {
|
||||
this.usersRepository.update(user.id, {
|
||||
|
@ -110,16 +122,23 @@ export class StreamingApiServerService {
|
|||
|
||||
connection.once('close', () => {
|
||||
ev.removeAllListeners();
|
||||
main.dispose();
|
||||
stream.dispose();
|
||||
this.redisForSub.off('message', onRedisMessage);
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
|
||||
connection.on('message', async (data) => {
|
||||
if (data.type === 'utf8' && data.utf8Data === 'ping') {
|
||||
if (data.toString() === 'ping') {
|
||||
connection.send('pong');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public detach(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.#wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ export default class extends Endpoint<'admin/relays/add'> {
|
|||
) {
|
||||
super(async (ps, me) => {
|
||||
try {
|
||||
if (new URL(ps.inbox).protocol !== 'https:') throw 'https only';
|
||||
if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only');
|
||||
} catch {
|
||||
throw new ApiError(this.meta.errors.invalidUrl);
|
||||
}
|
||||
|
|
|
@ -82,14 +82,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
|
||||
try {
|
||||
if (ps.tag) {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw 'Injection';
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
|
||||
} else {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
for (const tags of ps.query!) {
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
for (const tag of tags) {
|
||||
if (!safeForSql(normalizeForSearch(tag))) throw 'Injection';
|
||||
if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection');
|
||||
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
|
||||
}
|
||||
}));
|
||||
|
|
|
@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private redisClient: Redis.Redis,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
|
||||
|
||||
await redisClient.flushdb();
|
||||
await resetDb(this.db);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import * as WebSocket from 'ws';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
||||
import type { Packed } from 'misskey-js';
|
||||
|
@ -7,7 +8,6 @@ import { bindThis } from '@/decorators.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { UserProfile } from '@/models/index.js';
|
||||
import type { ChannelsService } from './ChannelsService.js';
|
||||
import type * as websocket from 'websocket';
|
||||
import type { EventEmitter } from 'events';
|
||||
import type Channel from './channel.js';
|
||||
import type { StreamEventEmitter, StreamMessages } from './types.js';
|
||||
|
@ -18,7 +18,7 @@ import type { StreamEventEmitter, StreamMessages } from './types.js';
|
|||
export default class Connection {
|
||||
public user?: User;
|
||||
public token?: AccessToken;
|
||||
private wsConnection: websocket.connection;
|
||||
private wsConnection: WebSocket.WebSocket;
|
||||
public subscriber: StreamEventEmitter;
|
||||
private channels: Channel[] = [];
|
||||
private subscribingNotes: any = {};
|
||||
|
@ -37,11 +37,9 @@ export default class Connection {
|
|||
private notificationService: NotificationService,
|
||||
private cacheService: CacheService,
|
||||
|
||||
subscriber: EventEmitter,
|
||||
user: User | null | undefined,
|
||||
token: AccessToken | null | undefined,
|
||||
) {
|
||||
this.subscriber = subscriber;
|
||||
if (user) this.user = user;
|
||||
if (token) this.token = token;
|
||||
}
|
||||
|
@ -70,12 +68,16 @@ export default class Connection {
|
|||
if (this.user != null) {
|
||||
await this.fetch();
|
||||
|
||||
this.fetchIntervalId = setInterval(this.fetch, 1000 * 10);
|
||||
if (!this.fetchIntervalId) {
|
||||
this.fetchIntervalId = setInterval(this.fetch, 1000 * 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init2(wsConnection: websocket.connection) {
|
||||
public async listen(subscriber: EventEmitter, wsConnection: WebSocket.WebSocket) {
|
||||
this.subscriber = subscriber;
|
||||
|
||||
this.wsConnection = wsConnection;
|
||||
this.wsConnection.on('message', this.onWsConnectionMessage);
|
||||
|
||||
|
@ -88,14 +90,11 @@ export default class Connection {
|
|||
* クライアントからメッセージ受信時
|
||||
*/
|
||||
@bindThis
|
||||
private async onWsConnectionMessage(data: websocket.Message) {
|
||||
if (data.type !== 'utf8') return;
|
||||
if (data.utf8Data == null) return;
|
||||
|
||||
private async onWsConnectionMessage(data: WebSocket.RawData) {
|
||||
let obj: Record<string, any>;
|
||||
|
||||
try {
|
||||
obj = JSON.parse(data.utf8Data);
|
||||
obj = JSON.parse(data.toString());
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -52,11 +52,7 @@ export class MockResolver extends Resolver {
|
|||
const r = this._rs.get(value);
|
||||
|
||||
if (!r) {
|
||||
throw {
|
||||
name: 'StatusError',
|
||||
statusCode: 404,
|
||||
message: 'Not registed for mock',
|
||||
};
|
||||
throw new Error('Not registed for mock');
|
||||
}
|
||||
|
||||
const object = JSON.parse(r.content);
|
||||
|
|
|
@ -482,7 +482,7 @@ export async function testPaginationConsistency<Entity extends { id: string, cre
|
|||
}
|
||||
|
||||
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
|
||||
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
|
||||
|
||||
const db = new DataSource({
|
||||
type: 'postgres',
|
||||
|
|
|
@ -56,6 +56,7 @@ module.exports = {
|
|||
'vue/require-v-for-key': 'warn',
|
||||
'vue/no-unused-components': 'warn',
|
||||
'vue/no-unused-vars': 'warn',
|
||||
'vue/no-dupe-keys': 'warn',
|
||||
'vue/valid-v-for': 'warn',
|
||||
'vue/return-in-computed-property': 'warn',
|
||||
'vue/no-setup-props-destructure': 'warn',
|
||||
|
|
|
@ -70,40 +70,40 @@
|
|||
"typescript": "5.0.4",
|
||||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vite": "4.3.8",
|
||||
"vite": "4.3.9",
|
||||
"vue": "3.3.4",
|
||||
"vue-plyr": "7.0.0",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "7.0.15",
|
||||
"@storybook/addon-essentials": "7.0.15",
|
||||
"@storybook/addon-interactions": "7.0.15",
|
||||
"@storybook/addon-links": "7.0.15",
|
||||
"@storybook/addon-storysource": "7.0.15",
|
||||
"@storybook/addons": "7.0.15",
|
||||
"@storybook/blocks": "7.0.15",
|
||||
"@storybook/core-events": "7.0.15",
|
||||
"@storybook/addon-actions": "7.0.18",
|
||||
"@storybook/addon-essentials": "7.0.18",
|
||||
"@storybook/addon-interactions": "7.0.18",
|
||||
"@storybook/addon-links": "7.0.18",
|
||||
"@storybook/addon-storysource": "7.0.18",
|
||||
"@storybook/addons": "7.0.18",
|
||||
"@storybook/blocks": "7.0.18",
|
||||
"@storybook/core-events": "7.0.18",
|
||||
"@storybook/jest": "0.1.0",
|
||||
"@storybook/manager-api": "7.0.15",
|
||||
"@storybook/preview-api": "7.0.15",
|
||||
"@storybook/react": "7.0.15",
|
||||
"@storybook/react-vite": "7.0.15",
|
||||
"@storybook/manager-api": "7.0.18",
|
||||
"@storybook/preview-api": "7.0.18",
|
||||
"@storybook/react": "7.0.18",
|
||||
"@storybook/react-vite": "7.0.18",
|
||||
"@storybook/testing-library": "0.1.0",
|
||||
"@storybook/theming": "7.0.15",
|
||||
"@storybook/types": "7.0.15",
|
||||
"@storybook/vue3": "7.0.15",
|
||||
"@storybook/vue3-vite": "7.0.15",
|
||||
"@storybook/theming": "7.0.18",
|
||||
"@storybook/types": "7.0.18",
|
||||
"@storybook/vue3": "7.0.18",
|
||||
"@storybook/vue3-vite": "7.0.18",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/vue": "7.0.0",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/estree": "1.0.1",
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.2",
|
||||
"@types/matter-js": "0.18.4",
|
||||
"@types/matter-js": "0.18.5",
|
||||
"@types/micromatch": "4.0.2",
|
||||
"@types/node": "20.2.3",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/seedrandom": "3.0.5",
|
||||
|
@ -123,7 +123,7 @@
|
|||
"cypress": "12.13.0",
|
||||
"eslint": "8.40.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-vue": "9.14.0",
|
||||
"eslint-plugin-vue": "9.14.1",
|
||||
"fast-glob": "3.2.12",
|
||||
"happy-dom": "9.19.2",
|
||||
"micromatch": "3.1.10",
|
||||
|
@ -133,7 +133,7 @@
|
|||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"storybook": "7.0.15",
|
||||
"storybook": "7.0.18",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vite-plugin-turbosnap": "1.0.2",
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
class="ncvczrfv"
|
||||
:class="{ isSelected }"
|
||||
:class="[$style.root, { [$style.isSelected]: isSelected }]"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
|
@ -9,25 +8,27 @@
|
|||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<div v-if="$i?.avatarId == file.id" class="label">
|
||||
<img src="/client-assets/label.svg"/>
|
||||
<p>{{ i18n.ts.avatar }}</p>
|
||||
</div>
|
||||
<div v-if="$i?.bannerId == file.id" class="label">
|
||||
<img src="/client-assets/label.svg"/>
|
||||
<p>{{ i18n.ts.banner }}</p>
|
||||
</div>
|
||||
<div v-if="file.isSensitive" class="label red">
|
||||
<img src="/client-assets/label-red.svg"/>
|
||||
<p>{{ i18n.ts.nsfw }}</p>
|
||||
</div>
|
||||
<div style="pointer-events: none;">
|
||||
<div v-if="$i?.avatarId == file.id" :class="[$style.label]">
|
||||
<img :class="$style.labelImg" src="/client-assets/label.svg"/>
|
||||
<p :class="$style.labelText">{{ i18n.ts.avatar }}</p>
|
||||
</div>
|
||||
<div v-if="$i?.bannerId == file.id" :class="[$style.label]">
|
||||
<img :class="$style.labelImg" src="/client-assets/label.svg"/>
|
||||
<p :class="$style.labelText">{{ i18n.ts.banner }}</p>
|
||||
</div>
|
||||
<div v-if="file.isSensitive" :class="[$style.label, $style.red]">
|
||||
<img :class="$style.labelImg" src="/client-assets/label-red.svg"/>
|
||||
<p :class="$style.labelText">{{ i18n.ts.nsfw }}</p>
|
||||
</div>
|
||||
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
|
||||
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/>
|
||||
|
||||
<p class="name">
|
||||
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
|
||||
<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
|
||||
</p>
|
||||
<p :class="$style.name">
|
||||
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
|
||||
<span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -88,20 +89,13 @@ function onDragend() {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ncvczrfv {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
padding: 8px 0 0 0;
|
||||
min-height: 180px;
|
||||
border-radius: 8px;
|
||||
|
||||
&, * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
|
@ -165,82 +159,78 @@ function onDragend() {
|
|||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 19px;
|
||||
left: -28px;
|
||||
width: 120px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
color: #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
|
||||
> .ext {
|
||||
opacity: 0.5;
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.labelImg {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.labelText {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 19px;
|
||||
left: -28px;
|
||||
width: 120px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
color: #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
class="rghtznwe"
|
||||
:class="{ draghover }"
|
||||
:class="[$style.root, { [$style.draghover]: draghover }]"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
|
@ -15,15 +14,15 @@
|
|||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<p class="name">
|
||||
<template v-if="hover"><i class="ti ti-folder ti-fw"></i></template>
|
||||
<template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template>
|
||||
<p :class="$style.name">
|
||||
<template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
|
||||
<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
|
||||
{{ folder.name }}
|
||||
</p>
|
||||
<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
|
||||
<p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload">
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
</p>
|
||||
<button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
|
||||
<button v-if="selectMode" class="_button" :class="[$style.checkbox, { [$style.checked]: isSelected }]" @click.prevent.stop="checkboxClicked"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -267,35 +266,14 @@ function onContextmenu(ev: MouseEvent) {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rghtznwe {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
height: 64px;
|
||||
background: var(--driveFolderBg);
|
||||
border-radius: 4px;
|
||||
|
||||
&, * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
*:not(.checkbox) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .checkbox {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border: solid 1px #000;
|
||||
|
||||
&.checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
|
||||
&.draghover {
|
||||
&:after {
|
||||
|
@ -310,24 +288,38 @@ function onContextmenu(ev: MouseEvent) {
|
|||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border: solid 1px #000;
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
margin-left: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
> .upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
&.checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 4px;
|
||||
margin-left: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="yfudmmck">
|
||||
<nav>
|
||||
<div class="path" @contextmenu.prevent.stop="() => {}">
|
||||
<div :class="$style.root">
|
||||
<nav :class="$style.nav">
|
||||
<div :class="$style.navPath" @contextmenu.prevent.stop="() => {}">
|
||||
<XNavFolder
|
||||
:class="{ current: folder == null }"
|
||||
:class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]"
|
||||
:parentFolder="folder"
|
||||
@move="move"
|
||||
@upload="upload"
|
||||
|
@ -11,37 +11,38 @@
|
|||
@removeFolder="removeFolder"
|
||||
/>
|
||||
<template v-for="f in hierarchyFolders">
|
||||
<span class="separator"><i class="ti ti-chevron-right"></i></span>
|
||||
<span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
|
||||
<XNavFolder
|
||||
:folder="f"
|
||||
:parentFolder="folder"
|
||||
:class="[$style.navPathItem]"
|
||||
@move="move"
|
||||
@upload="upload"
|
||||
@removeFile="removeFile"
|
||||
@removeFolder="removeFolder"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
|
||||
<span v-if="folder != null" class="folder current">{{ folder.name }}</span>
|
||||
<span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
|
||||
<span v-if="folder != null" :class="[$style.navPathItem, $style.navCurrent]">{{ folder.name }}</span>
|
||||
</div>
|
||||
<button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
|
||||
<button class="_button" :class="$style.navMenu" @click="showMenu"><i class="ti ti-dots"></i></button>
|
||||
</nav>
|
||||
<div
|
||||
ref="main" class="main"
|
||||
:class="{ uploading: uploadings.length > 0, fetching }"
|
||||
ref="main"
|
||||
:class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<div ref="contents" class="contents">
|
||||
<div v-show="folders.length > 0" ref="foldersContainer" class="folders">
|
||||
<div ref="contents">
|
||||
<div v-show="folders.length > 0" ref="foldersContainer" :class="$style.folders">
|
||||
<XFolder
|
||||
v-for="(f, i) in folders"
|
||||
:key="f.id"
|
||||
v-anim="i"
|
||||
class="folder"
|
||||
:class="$style.folder"
|
||||
:folder="f"
|
||||
:selectMode="select === 'folder'"
|
||||
:isSelected="selectedFolders.some(x => x.id === f.id)"
|
||||
|
@ -54,15 +55,15 @@
|
|||
@dragend="isDragSource = false"
|
||||
/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
|
||||
<div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
|
||||
<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
|
||||
</div>
|
||||
<div v-show="files.length > 0" ref="filesContainer" class="files">
|
||||
<div v-show="files.length > 0" ref="filesContainer" :class="$style.files">
|
||||
<XFile
|
||||
v-for="(file, i) in files"
|
||||
:key="file.id"
|
||||
v-anim="i"
|
||||
class="file"
|
||||
:class="$style.file"
|
||||
:file="file"
|
||||
:selectMode="select === 'file'"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
|
@ -71,19 +72,19 @@
|
|||
@dragend="isDragSource = false"
|
||||
/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
|
||||
<div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
|
||||
<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
|
||||
</div>
|
||||
<div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty">
|
||||
<p v-if="draghover">{{ i18n.t('empty-draghover') }}</p>
|
||||
<p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p>
|
||||
<p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p>
|
||||
<div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty">
|
||||
<div v-if="draghover">{{ i18n.t('empty-draghover') }}</div>
|
||||
<div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</div>
|
||||
<div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
<div v-if="draghover" class="dropzone"></div>
|
||||
<input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
|
||||
<div v-if="draghover" :class="$style.dropzone"></div>
|
||||
<input ref="fileInput" style="display: none;" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -658,147 +659,116 @@ onBeforeUnmount(() => {
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yfudmmck {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> nav {
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
font-size: 0.9em;
|
||||
box-shadow: 0 1px 0 var(--divider);
|
||||
.nav {
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
font-size: 0.9em;
|
||||
box-shadow: 0 1px 0 var(--divider);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
.navPath {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
line-height: 42px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> .path {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
line-height: 42px;
|
||||
white-space: nowrap;
|
||||
.navPathItem {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
line-height: 42px;
|
||||
cursor: pointer;
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
line-height: 42px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
&.navCurrent {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.current {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.separator {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
|
||||
> i {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .menu {
|
||||
margin-left: auto;
|
||||
padding: 0 12px;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--margin);
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait !important;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .contents {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
height: calc(100% - 38px - 100px);
|
||||
}
|
||||
|
||||
> .contents {
|
||||
|
||||
> .folders,
|
||||
> .files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .folder,
|
||||
> .file {
|
||||
flex-grow: 1;
|
||||
width: 128px;
|
||||
margin: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> .padding {
|
||||
flex-grow: 1;
|
||||
pointer-events: none;
|
||||
width: 128px + 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.navSeparator {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
> .dropzone {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 38px;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
border: dashed 2px var(--focus);
|
||||
.navMenu {
|
||||
margin-left: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--margin);
|
||||
user-select: none;
|
||||
|
||||
&.fetching {
|
||||
cursor: wait !important;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
&.uploading {
|
||||
height: calc(100% - 38px - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
.folders,
|
||||
.files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.folder,
|
||||
.file {
|
||||
flex-grow: 1;
|
||||
width: 128px;
|
||||
margin: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.padding {
|
||||
flex-grow: 1;
|
||||
pointer-events: none;
|
||||
width: 128px + 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 38px;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
border: dashed 2px var(--focus);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')">
|
||||
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
|
||||
<div ref="headerEl" :class="$style.header">
|
||||
<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
|
||||
|
|
|
@ -4,25 +4,25 @@
|
|||
v-show="!isDeleted"
|
||||
ref="el"
|
||||
v-hotkey="keymap"
|
||||
class="lxwezrsl"
|
||||
:tabindex="!isDeleted ? '-1' : null"
|
||||
:class="{ renote: isRenote }"
|
||||
:class="[$style.root, { [$style.renote]: isRenote }]"
|
||||
>
|
||||
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
|
||||
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
|
||||
<div v-if="isRenote" class="renote">
|
||||
<MkAvatar class="avatar" :user="note.user" link preview/>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<I18n :src="i18n.ts.renotedBy" tag="span">
|
||||
<template #user>
|
||||
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<div class="info">
|
||||
<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
|
||||
<i v-if="isMyRenote" class="ti ti-dots dropdownIcon"></i>
|
||||
<MkNoteSub v-for="note in conversation" :key="note.id" :class="$style.replyToMore" :note="note"/>
|
||||
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
|
||||
<div v-if="isRenote" :class="$style.renote">
|
||||
<MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/>
|
||||
<i class="ti ti-repeat" style="margin-right: 4px;"></i>
|
||||
<span :class="$style.renoteText">
|
||||
<I18n :src="i18n.ts.renotedBy" tag="span">
|
||||
<template #user>
|
||||
<MkA v-user-preview="note.userId" :class="$style.renoteName" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
</span>
|
||||
<div :class="$style.renoteInfo">
|
||||
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
|
||||
<i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
|
||||
<MkTime :time="note.createdAt"/>
|
||||
</button>
|
||||
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
|
||||
|
@ -33,16 +33,16 @@
|
|||
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<article class="article" @contextmenu.stop="onContextmenu">
|
||||
<header class="header">
|
||||
<MkAvatar class="avatar" :user="appearNote.user" indicator link preview/>
|
||||
<div class="body">
|
||||
<div class="top">
|
||||
<MkA v-user-preview="appearNote.user.id" class="name" :to="userPage(appearNote.user)">
|
||||
<article :class="$style.note" @contextmenu.stop="onContextmenu">
|
||||
<header :class="$style.noteHeader">
|
||||
<MkAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link preview/>
|
||||
<div :class="$style.noteHeaderBody">
|
||||
<div>
|
||||
<MkA v-user-preview="appearNote.user.id" :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
|
||||
<MkUserName :nowrap="false" :user="appearNote.user"/>
|
||||
</MkA>
|
||||
<span v-if="appearNote.user.isBot" class="is-bot">bot</span>
|
||||
<div class="info">
|
||||
<span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
|
||||
<div :class="$style.noteHeaderInfo">
|
||||
<span v-if="appearNote.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
|
||||
<i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
|
||||
<i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
|
@ -51,84 +51,81 @@
|
|||
<span v-if="appearNote.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username"><MkAcct :user="appearNote.user"/></div>
|
||||
<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
|
||||
<div :class="$style.noteHeaderUsername"><MkAcct :user="appearNote.user"/></div>
|
||||
<MkInstanceTicker v-if="showTicker" :class="$style.ticker" :instance="appearNote.user.instance"/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main">
|
||||
<div class="body">
|
||||
<p v-if="appearNote.cw != null" class="cw">
|
||||
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
|
||||
<MkCwButton v-model="showContent" :note="appearNote"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent" class="content">
|
||||
<div class="text">
|
||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
||||
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
|
||||
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
|
||||
<a v-if="appearNote.renote != null" class="rp">RN:</a>
|
||||
<div v-if="translating || translation" class="translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else class="translated">
|
||||
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.noteContent">
|
||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
|
||||
<MkCwButton v-model="showContent" :note="appearNote"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent">
|
||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
|
||||
<MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
|
||||
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
|
||||
<a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
|
||||
<div v-if="translating || translation" :class="$style.translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else>
|
||||
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :emojiUrls="appearNote.emojis"/>
|
||||
</div>
|
||||
<div v-if="appearNote.files.length > 0" class="files">
|
||||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/>
|
||||
<div v-if="appearNote.renote" class="renote"><MkNoteSimple :note="appearNote.renote" class="note"/></div>
|
||||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||
<div v-if="appearNote.files.length > 0" :class="$style.files">
|
||||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
|
||||
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="info">
|
||||
<MkA class="created-at" :to="notePage(appearNote)">
|
||||
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
||||
</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
|
||||
<button class="button _button" @click="reply()">
|
||||
<i class="ti ti-arrow-back-up"></i>
|
||||
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
ref="renoteButton"
|
||||
class="button _button"
|
||||
@mousedown="renote()"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" class="count">{{ appearNote.renoteCount }}</p>
|
||||
</button>
|
||||
<button v-else class="button _button" disabled>
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @mousedown="react()">
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
|
||||
<i class="ti ti-minus"></i>
|
||||
</button>
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="button _button" @mousedown="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button ref="menuButton" class="button _button" @mousedown="menu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
<footer>
|
||||
<div :class="$style.noteFooterInfo">
|
||||
<MkA :to="notePage(appearNote)">
|
||||
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
||||
</MkA>
|
||||
</div>
|
||||
<MkReactionsViewer ref="reactionsViewer" :note="appearNote"/>
|
||||
<button class="_button" :class="$style.noteFooterButton" @click="reply()">
|
||||
<i class="ti ti-arrow-back-up"></i>
|
||||
<p v-if="appearNote.repliesCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.repliesCount }}</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRenote"
|
||||
ref="renoteButton"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
@mousedown="renote()"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ appearNote.renoteCount }}</p>
|
||||
</button>
|
||||
<button v-else class="_button" :class="$style.noteFooterButton" disabled>
|
||||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.noteFooterButton" class="_button" @mousedown="react()">
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction != null" ref="reactButton" class="_button" :class="[$style.noteFooterButton, $style.reacted]" @click="undoReact(appearNote)">
|
||||
<i class="ti ti-minus"></i>
|
||||
</button>
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="menu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
|
||||
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
|
||||
</div>
|
||||
<div v-else class="_panel muted" @click="muted = false">
|
||||
<div v-else class="_panel" :class="$style.muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
|
@ -438,318 +435,249 @@ if (appearNote.replyId) {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lxwezrsl {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
transition: box-shadow 0.1s ease;
|
||||
overflow: clip;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
.replyTo {
|
||||
opacity: 0.7;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% - 8px);
|
||||
border: dashed 1px var(--focus);
|
||||
border-radius: var(--radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
.replyToMore {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover > .article > .main > .footer > .button {
|
||||
.renote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 32px 8px 32px;
|
||||
line-height: 28px;
|
||||
white-space: pre;
|
||||
color: var(--renote);
|
||||
}
|
||||
|
||||
.renoteAvatar {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 8px 0 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.renoteText {
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renoteName {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.renoteInfo {
|
||||
margin-left: auto;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.renoteTime {
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.renote + .note {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 32px;
|
||||
font-size: 1.2em;
|
||||
|
||||
&:hover > .main > .footer > .button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> .reply-to {
|
||||
opacity: 0.7;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
> .reply-to-more {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> .renote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 32px 8px 32px;
|
||||
line-height: 28px;
|
||||
white-space: pre;
|
||||
color: var(--renote);
|
||||
|
||||
> .avatar {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 8px 0 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
> .info {
|
||||
margin-left: auto;
|
||||
font-size: 0.9em;
|
||||
|
||||
> .time {
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
|
||||
> .dropdownIcon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .renote + .article {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
> .article {
|
||||
padding: 32px;
|
||||
font-size: 1.2em;
|
||||
|
||||
> .header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
|
||||
> .avatar {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-left: 16px;
|
||||
font-size: 0.95em;
|
||||
|
||||
> .top {
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
> .is-bot {
|
||||
display: inline-block;
|
||||
margin: 0 0.5em;
|
||||
padding: 4px 6px;
|
||||
font-size: 80%;
|
||||
line-height: 1;
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
> .info {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
> .username {
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.3;
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .main {
|
||||
> .body {
|
||||
container-type: inline-size;
|
||||
|
||||
> .cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> .reply {
|
||||
color: var(--accent);
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
> .rp {
|
||||
margin-left: 4px;
|
||||
font-style: oblique;
|
||||
color: var(--renote);
|
||||
}
|
||||
|
||||
> .translation {
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .url-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
> .poll {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
> .renote {
|
||||
padding: 8px 0;
|
||||
|
||||
> .note {
|
||||
padding: 16px;
|
||||
border: dashed 1px var(--renote);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .channel {
|
||||
opacity: 0.7;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
> .footer {
|
||||
> .info {
|
||||
margin: 16px 0;
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
> .button {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
|
||||
> .count {
|
||||
display: inline;
|
||||
margin: 0 0 0 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.reacted {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .reply {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
|
||||
.noteHeader {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.noteHeaderAvatar {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.noteHeaderBody {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-left: 16px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.noteHeaderName {
|
||||
font-weight: bold;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.isBot {
|
||||
display: inline-block;
|
||||
margin: 0 0.5em;
|
||||
padding: 4px 6px;
|
||||
font-size: 80%;
|
||||
line-height: 1;
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.noteHeaderInfo {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.noteHeaderUsername {
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.3;
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
.noteContent {
|
||||
container-type: inline-size;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.noteReplyTarget {
|
||||
color: var(--accent);
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.rn {
|
||||
margin-left: 4px;
|
||||
font-style: oblique;
|
||||
color: var(--renote);
|
||||
}
|
||||
|
||||
.translation {
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.poll {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.quote {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.quoteNote {
|
||||
padding: 16px;
|
||||
border: dashed 1px var(--renote);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.channel {
|
||||
opacity: 0.7;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.noteFooterInfo {
|
||||
margin: 16px 0;
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.noteFooterButton {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
opacity: 0.7;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
}
|
||||
|
||||
.noteFooterButtonCount {
|
||||
display: inline;
|
||||
margin: 0 0 0 8px;
|
||||
opacity: 0.7;
|
||||
|
||||
&.reacted {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.reply {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
@container (max-width: 500px) {
|
||||
.lxwezrsl {
|
||||
.root {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 450px) {
|
||||
.lxwezrsl {
|
||||
> .renote {
|
||||
padding: 8px 16px 0 16px;
|
||||
}
|
||||
.renote {
|
||||
padding: 8px 16px 0 16px;
|
||||
}
|
||||
|
||||
> .article {
|
||||
padding: 16px;
|
||||
.note {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
> .header {
|
||||
> .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.noteHeaderAvatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
.lxwezrsl {
|
||||
> .article {
|
||||
> .main {
|
||||
> .footer {
|
||||
> .button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.noteFooterButton {
|
||||
&:not(:last-child) {
|
||||
margin-right: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.lxwezrsl {
|
||||
.root {
|
||||
font-size: 0.825em;
|
||||
}
|
||||
|
||||
> .article {
|
||||
> .header {
|
||||
> .avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
.noteHeaderAvatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
> .main {
|
||||
> .footer {
|
||||
> .button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.noteFooterButton {
|
||||
&:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,11 +25,11 @@
|
|||
</div>
|
||||
|
||||
<div v-if="type === 'and' || type === 'or'" :class="$style.values" class="_gaps">
|
||||
<Sortable v-model="v.values" tag="div" class="_gaps" item-key="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swap-threshold="0.5">
|
||||
<Sortable v-model="v.values" tag="div" class="_gaps" itemKey="id" handle=".drag-handle" :group="{ name: 'roleFormula' }" :animation="150" :swapThreshold="0.5">
|
||||
<template #item="{element}">
|
||||
<div :class="$style.item">
|
||||
<!-- divが無いとエラーになる https://github.com/SortableJS/vue.draggable.next/issues/189 -->
|
||||
<RolesEditorFormula :model-value="element" draggable @update:model-value="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/>
|
||||
<RolesEditorFormula :modelValue="element" draggable @update:modelValue="updated => valuesItemUpdated(updated)" @remove="removeItem(element)"/>
|
||||
</div>
|
||||
</template>
|
||||
</Sortable>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div>
|
||||
<MkStickyContainer>
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700">
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="_gaps">
|
||||
<div class="_buttons">
|
||||
<MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
|
||||
|
@ -11,9 +11,9 @@
|
|||
<MkFolder>
|
||||
<template #icon><i class="ti ti-info-circle"></i></template>
|
||||
<template #label>{{ i18n.ts.info }}</template>
|
||||
<XEditor :model-value="role" readonly/>
|
||||
<XEditor :modelValue="role" readonly/>
|
||||
</MkFolder>
|
||||
<MkFolder v-if="role.target === 'manual'" default-open>
|
||||
<MkFolder v-if="role.target === 'manual'" defaultOpen>
|
||||
<template #icon><i class="ti ti-users"></i></template>
|
||||
<template #label>{{ i18n.ts.users }}</template>
|
||||
<template #suffix>{{ role.usersCount }}</template>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div>
|
||||
<MkStickyContainer>
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700">
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="_gaps">
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts._role.baseRole }}</template>
|
||||
|
@ -14,7 +14,7 @@
|
|||
<MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])">
|
||||
<template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template>
|
||||
<template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template>
|
||||
<MkRange :model-value="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :text-converter="(v) => `${v}%`" @update:model-value="v => policies.rateLimitFactor = (v / 100)">
|
||||
<MkRange :modelValue="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => policies.rateLimitFactor = (v / 100)">
|
||||
<template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template>
|
||||
</MkRange>
|
||||
</MkFolder>
|
||||
|
@ -156,13 +156,13 @@
|
|||
<MkFoldableSection>
|
||||
<template #header>Manual roles</template>
|
||||
<div class="_gaps_s">
|
||||
<MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :for-moderation="true"/>
|
||||
<MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :forModeration="true"/>
|
||||
</div>
|
||||
</MkFoldableSection>
|
||||
<MkFoldableSection>
|
||||
<template #header>Conditional roles</template>
|
||||
<div class="_gaps_s">
|
||||
<MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :for-moderation="true"/>
|
||||
<MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :forModeration="true"/>
|
||||
</div>
|
||||
</MkFoldableSection>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
|
||||
<FormSuspense :p="init">
|
||||
<div class="_gaps_m">
|
||||
<MkFolder>
|
||||
|
@ -33,7 +33,7 @@
|
|||
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
|
||||
</MkRadios>
|
||||
|
||||
<MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`">
|
||||
<MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
|
||||
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
|
||||
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
|
||||
</MkRange>
|
||||
|
@ -65,7 +65,7 @@
|
|||
|
||||
<div class="_gaps_m">
|
||||
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
|
||||
<MkSwitch v-model="enableActiveEmailValidation" @update:model-value="save">
|
||||
<MkSwitch v-model="enableActiveEmailValidation" @update:modelValue="save">
|
||||
<template #label>Enable</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
|
@ -77,7 +77,7 @@
|
|||
<template v-else #suffix>Disabled</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="enableIpLogging" @update:model-value="save">
|
||||
<MkSwitch v-model="enableIpLogging" @update:modelValue="save">
|
||||
<template #label>Enable</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
|
||||
<MkSpacer :contentMax="800" :marginMin="16" :marginMax="32">
|
||||
<FormSuspense :p="init" class="_gaps">
|
||||
<MkInput v-model="title">
|
||||
<template #label>{{ i18n.ts.title }}</template>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="1400">
|
||||
<MkSpacer :contentMax="1400">
|
||||
<div class="_root">
|
||||
<div v-if="tab === 'explore'">
|
||||
<MkFoldableSection class="_margin">
|
||||
<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
|
||||
<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
|
||||
<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disableAutoLoad="true">
|
||||
<div :class="$style.items">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
|
@ -14,7 +14,7 @@
|
|||
</MkFoldableSection>
|
||||
<MkFoldableSection class="_margin">
|
||||
<template #header><i class="ti ti-comet"></i>{{ i18n.ts.popularPosts }}</template>
|
||||
<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
|
||||
<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disableAutoLoad="true">
|
||||
<div :class="$style.items">
|
||||
<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="1000" :margin-min="16" :margin-max="32">
|
||||
<MkSpacer :contentMax="1000" :marginMin="16" :marginMax="32">
|
||||
<div class="_root">
|
||||
<Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<div v-if="post" class="rkxwuolj">
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</template>
|
||||
|
||||
<div style="padding: 8px;">
|
||||
<MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/>
|
||||
<MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspectRatio="5"/>
|
||||
</div>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
|
|
@ -5,15 +5,16 @@ const dateTimeIntervals = {
|
|||
};
|
||||
|
||||
export function dateUTC(time: number[]): Date {
|
||||
const d = time.length === 2 ? Date.UTC(time[0], time[1])
|
||||
: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
|
||||
: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
|
||||
: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
|
||||
: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
|
||||
: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
|
||||
: null;
|
||||
const d =
|
||||
time.length === 2 ? Date.UTC(time[0], time[1])
|
||||
: time.length === 3 ? Date.UTC(time[0], time[1], time[2])
|
||||
: time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
|
||||
: time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
|
||||
: time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
|
||||
: time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
|
||||
: null;
|
||||
|
||||
if (!d) throw 'wrong number of arguments';
|
||||
if (!d) throw new Error('wrong number of arguments');
|
||||
|
||||
return new Date(d);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
<template>
|
||||
<span v-if="!fetching" class="nmidsaqw">
|
||||
<span v-if="!fetching" :class="$style.root">
|
||||
<template v-if="display === 'marquee'">
|
||||
<Transition name="change" mode="default">
|
||||
<Transition
|
||||
:enterActiveClass="$style.transition_change_enterActive"
|
||||
:leaveActiveClass="$style.transition_change_leaveActive"
|
||||
:enterFromClass="$style.transition_change_enterFrom"
|
||||
:leaveToClass="$style.transition_change_leaveTo"
|
||||
mode="default"
|
||||
>
|
||||
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
|
||||
<span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
|
||||
<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
|
||||
<MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
|
||||
<span v-for="instance in instances" :key="instance.id" :class="[$style.item, { [$style.colored]: colored }]" :style="{ background: colored ? instance.themeColor : null }">
|
||||
<img :class="$style.icon" :src="getInstanceIcon(instance)" alt=""/>
|
||||
<MkA :to="`/instance-info/${instance.host}`" :class="$style.host" class="_monospace">
|
||||
{{ instance.host }}
|
||||
</MkA>
|
||||
<span class="divider"></span>
|
||||
<span :class="$style.divider"></span>
|
||||
</span>
|
||||
</MarqueeText>
|
||||
</Transition>
|
||||
|
@ -61,46 +67,47 @@ function getInstanceIcon(instance): string {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-enter-active, .change-leave-active {
|
||||
<style lang="scss" module>
|
||||
.transition_change_enterActive,
|
||||
.transition_change_leaveActive {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: all 1s ease;
|
||||
}
|
||||
.change-enter-from {
|
||||
opacity: 0;
|
||||
.transition_change_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.change-leave-to {
|
||||
opacity: 0;
|
||||
.transition_change_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.nmidsaqw {
|
||||
.root {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::v-deep(.item) {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
margin-right: 5em;
|
||||
.item {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
margin-right: 5em;
|
||||
|
||||
> .icon {
|
||||
display: inline-block;
|
||||
height: var(--height);
|
||||
aspect-ratio: 1;
|
||||
vertical-align: bottom;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
> .host {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
&.colored {
|
||||
padding-right: 1em;
|
||||
color: #fff;
|
||||
}
|
||||
&.colored {
|
||||
padding-right: 1em;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
height: var(--height);
|
||||
aspect-ratio: 1;
|
||||
vertical-align: bottom;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.host {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
<template>
|
||||
<span v-if="!fetching" class="xbhtxfms">
|
||||
<span v-if="!fetching" :class="$style.root">
|
||||
<template v-if="display === 'marquee'">
|
||||
<Transition name="change" mode="default">
|
||||
<Transition
|
||||
:enterActiveClass="$style.transition_change_enterActive"
|
||||
:leaveActiveClass="$style.transition_change_leaveActive"
|
||||
:enterFromClass="$style.transition_change_enterFrom"
|
||||
:leaveToClass="$style.transition_change_leaveTo"
|
||||
mode="default"
|
||||
>
|
||||
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
|
||||
<span v-for="item in items" class="item">
|
||||
<a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
|
||||
<span v-for="item in items" :class="$style.item">
|
||||
<a :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span :class="$style.divider"></span>
|
||||
</span>
|
||||
</MarqueeText>
|
||||
</Transition>
|
||||
|
@ -54,39 +60,40 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-enter-active, .change-leave-active {
|
||||
<style lang="scss" module>
|
||||
.transition_change_enterActive,
|
||||
.transition_change_leaveActive {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: all 1s ease;
|
||||
}
|
||||
.change-enter-from {
|
||||
opacity: 0;
|
||||
.transition_change_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.change-leave-to {
|
||||
opacity: 0;
|
||||
.transition_change_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.xbhtxfms {
|
||||
.root {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::v-deep(.item) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: bottom;
|
||||
margin: 0;
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: bottom;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .divider {
|
||||
display: inline-block;
|
||||
width: 0.5px;
|
||||
height: var(--height);
|
||||
margin: 0 3em;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
display: inline-block;
|
||||
width: 0.5px;
|
||||
height: var(--height);
|
||||
margin: 0 3em;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
<template>
|
||||
<span v-if="!fetching" class="osdsvwzy">
|
||||
<span v-if="!fetching" :class="$style.root">
|
||||
<template v-if="display === 'marquee'">
|
||||
<Transition name="change" mode="default">
|
||||
<Transition
|
||||
:enterActiveClass="$style.transition_change_enterActive"
|
||||
:leaveActiveClass="$style.transition_change_leaveActive"
|
||||
:enterFromClass="$style.transition_change_enterFrom"
|
||||
:leaveToClass="$style.transition_change_leaveTo"
|
||||
mode="default"
|
||||
>
|
||||
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
|
||||
<span v-for="note in notes" :key="note.id" class="item">
|
||||
<img class="avatar" :src="note.user.avatarUrl" decoding="async"/>
|
||||
<MkA class="text" :to="notePage(note)">
|
||||
<Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true"/>
|
||||
<span v-for="note in notes" :key="note.id" :class="$style.item">
|
||||
<img :class="$style.avatar" :src="note.user.avatarUrl" decoding="async"/>
|
||||
<MkA :class="$style.text" :to="notePage(note)">
|
||||
<Mfm :text="getNoteSummary(note)" :plain="true" :nowrap="true"/>
|
||||
</MkA>
|
||||
<span class="divider"></span>
|
||||
<span :class="$style.divider"></span>
|
||||
</span>
|
||||
</MarqueeText>
|
||||
</Transition>
|
||||
|
@ -60,54 +66,53 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-enter-active, .change-leave-active {
|
||||
<style lang="scss" module>
|
||||
.transition_change_enterActive,
|
||||
.transition_change_leaveActive {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transition: all 1s ease;
|
||||
}
|
||||
.change-enter-from {
|
||||
opacity: 0;
|
||||
.transition_change_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.change-leave-to {
|
||||
opacity: 0;
|
||||
.transition_change_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.osdsvwzy {
|
||||
.root {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::v-deep(.item) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: bottom;
|
||||
margin: 0;
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: bottom;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .avatar {
|
||||
display: inline-block;
|
||||
height: var(--height);
|
||||
aspect-ratio: 1;
|
||||
vertical-align: bottom;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.avatar {
|
||||
display: inline-block;
|
||||
height: var(--height);
|
||||
aspect-ratio: 1;
|
||||
vertical-align: bottom;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
> .text {
|
||||
> .text {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
.text {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
> .divider {
|
||||
display: inline-block;
|
||||
width: 0.5px;
|
||||
height: 16px;
|
||||
margin: 0 3em;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
display: inline-block;
|
||||
width: 0.5px;
|
||||
height: 16px;
|
||||
margin: 0 3em;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -330,11 +330,7 @@ async function deleteProfile() {
|
|||
flex-shrink: 0;
|
||||
padding-top: var(--columnGap);
|
||||
padding-bottom: var(--columnGap);
|
||||
padding-right: var(--columnGap);
|
||||
|
||||
&:first-of-type {
|
||||
padding-left: var(--columnGap);
|
||||
}
|
||||
padding-left: var(--columnGap);
|
||||
|
||||
> .column:not(:last-of-type) {
|
||||
margin-bottom: var(--columnGap);
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue