mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-01 21:06:05 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 198eebaf4d |
@@ -1,15 +1,3 @@
|
|||||||
## Unreleased
|
|
||||||
|
|
||||||
### General
|
|
||||||
-
|
|
||||||
|
|
||||||
### Client
|
|
||||||
-
|
|
||||||
|
|
||||||
### Server
|
|
||||||
-
|
|
||||||
|
|
||||||
|
|
||||||
## 2026.7.0
|
## 2026.7.0
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
---
|
|
||||||
_lang_: "فارسی"
|
|
||||||
headlineMisskey: "یک شبکه از طریق یادداشت ها متصل گردید."
|
|
||||||
introMisskey: "خوش آمدید! Misskey یک سرویس میکروبلاگینگ متن باز و غیرمتمرکز می باشد.\nیک \"یادداشت\" درست کن و در مورد اتفاق هایی که داره می افته و یا به دیگران درمورد خودت بگو📡\nشما می توانید به سرعت با کمک قابلیت \"واکنش ها\" به یادداشت های دیگران واکنش نشون بدید👍\nیک دنیای جدید رو کاوش کنید🚀"
|
|
||||||
poweredByMisskeyDescription: "{name} یکی از سرور های متن باز پلتفرم <b>Misskey</b> می باشد."
|
|
||||||
monthAndDay: "{day}/{month}"
|
|
||||||
search: "جستجو"
|
|
||||||
reset: "بازنشانی"
|
|
||||||
notifications: "اعلان ها"
|
|
||||||
username: "نام کاربری"
|
|
||||||
password: "گذرواژه"
|
|
||||||
initialPasswordForSetup: "گذرواژه برای راهاندازی تنظیمات اولیه"
|
|
||||||
initialPasswordIsIncorrect: "گذرواژه برای راهاندازی تنظیمات اولیه نادرست می باشد."
|
|
||||||
initialPasswordForSetupDescription: "اگر خودتان Misskey را نصب کرده اید، لطفا از گذرواژه ای که در فایل تنظیمات وارد کرده اید استفاده کنید.\nاگه از سرویس میزبانی برای Misskey استفاده می کنید، لطفا از گذرواژه ای که توسط آنها ارائه شده استفاده کنید.\nاگر گذرواژه ای تنظیم نکردید، بخش را خالی بگذارید و ادامه دهید."
|
|
||||||
forgotPassword: "گذرواژه ام را فراموش کردم"
|
|
||||||
fetchingAsApObject: "در حال دریافت از فدیوِرس"
|
|
||||||
ok: "باشه"
|
|
||||||
gotIt: "متوجه شدم"
|
|
||||||
cancel: "لغو"
|
|
||||||
noThankYou: "الان نه"
|
|
||||||
enterUsername: "نام کاربری خود را وارد کنید"
|
|
||||||
renotedBy: "بازنشر شده توسط {user}"
|
|
||||||
noNotes: "هیچ یادداشتی موجود نیست"
|
|
||||||
noNotifications: "اعلانی موجود نیست"
|
|
||||||
instance: "سرور"
|
|
||||||
settings: "تنظیمات"
|
|
||||||
notificationSettings: "تنظیمات اعلان"
|
|
||||||
basicSettings: "تنظیمات اصلی"
|
|
||||||
otherSettings: "تنظیمات بیشتر"
|
|
||||||
openInWindow: "در پنجره باز کن"
|
|
||||||
profile: "نمایه"
|
|
||||||
timeline: "نوار زمانی"
|
|
||||||
noAccountDescription: "این کاربر هنوز درون شرح حال خودش چیزی ننوشته."
|
|
||||||
login: "ورود"
|
|
||||||
loggingIn: "وارد شده"
|
|
||||||
logout: "خروج"
|
|
||||||
signup: "ثبتنام"
|
|
||||||
uploading: "بارگذاری..."
|
|
||||||
save: "ذخیره"
|
|
||||||
users: "کاربران"
|
|
||||||
addUser: "افزودن کاربر"
|
|
||||||
favorite: "افزودن به برگزیده ها"
|
|
||||||
favorites: "برگزیده ها"
|
|
||||||
unfavorite: "حذف از برگزیده ها"
|
|
||||||
favorited: "به برگزیده ها اضافه شد."
|
|
||||||
alreadyFavorited: "قبلا به برگزیده ها اضافه شده."
|
|
||||||
cantFavorite: "نمی توان به برگزیده ها اضافه کرد."
|
|
||||||
pin: "سنجاق کردن"
|
|
||||||
unpin: "حذف سنجاق"
|
|
||||||
copyContent: "رونوشت از محتوا"
|
|
||||||
copyLink: "رونوشت از پیوند"
|
|
||||||
copyRemoteLink: "رونوشت از پیوند خارجی"
|
|
||||||
copyLinkRenote: "رونوشت از پیوند بازنشر"
|
|
||||||
delete: "حذف"
|
|
||||||
deleteAndEdit: "حذف و ویرایش"
|
|
||||||
deleteAndEditConfirm: "آیا می خواهید که این یادداشت را حذف و دوباره ویرایش کنید؟ در این صورت همچنین تمام واکنش ها، بازنشر ها و پاسخ ها به این یادداشت پاک خواهد شد."
|
|
||||||
addToList: "افزودن به فهرست"
|
|
||||||
addToAntenna: "افزودن به آنتن"
|
|
||||||
sendMessage: "ارسال پیام"
|
|
||||||
copyRSS: "رونوشت RSS"
|
|
||||||
copyUsername: "رونوشت نام کاربری"
|
|
||||||
copyUserId: "رونوشت شناسه کاربری"
|
|
||||||
copyNoteId: "رونوشت شناسه یادداشت"
|
|
||||||
copyFileId: "رونوشت شناسه پرونده"
|
|
||||||
copyFolderId: "رونوشت شناسه پوشه"
|
|
||||||
copyProfileUrl: "رونوشت ادرس نمایه"
|
|
||||||
searchUser: "جستجوی کاربران"
|
|
||||||
searchThisUsersNotes: "جستجو در یادداشت های این کاربر"
|
|
||||||
reply: "پاسخ"
|
|
||||||
loadMore: "بارگیری بیشتر"
|
|
||||||
showMore: "نمایش بیشتر"
|
|
||||||
showLess: "بستن"
|
|
||||||
youGotNewFollower: "شما دنبال شده اید"
|
|
||||||
receiveFollowRequest: "درخواست دنبال کردن دریافت شد"
|
|
||||||
followRequestAccepted: "درخواست دنبال کردن قبول شد"
|
|
||||||
mention: "اشاره"
|
|
||||||
mentions: "اشاره کردن"
|
|
||||||
directNotes: "یادداشت مستقیم"
|
|
||||||
importAndExport: "واردسازی و خارجسازی"
|
|
||||||
import: "واردسازی"
|
|
||||||
export: "خارجسازی"
|
|
||||||
files: "پرونده ها"
|
|
||||||
download: "بارگیری"
|
|
||||||
driveFileDeleteConfirm: "آیا مطمئن هستید که می خواهید \"{name}\" را حذف کنید؟ تمام یادداشت هایی که این فایل به آنها پیوست خورده اند نیز پاک خواهد شد."
|
|
||||||
unfollowConfirm: "آیا میخواهید دیگر {name} را دنبال نکنید؟"
|
|
||||||
cancelFollowRequestConfirm: "آیا می خواهید درخواست دنبال کردن از {name} را لغو کنید؟"
|
|
||||||
rejectFollowRequestConfirm: "آیا می خواهید درخواست دنبال کردن از طرف {name} را رد کنید؟"
|
|
||||||
exportRequested: "شما یک درخواست خارجسازی کرده اید. این ممکن است کمی طول بکشد. وقتی که به اتمام رسید در حافظه شما ذخیره خواهد شد."
|
|
||||||
importRequested: "شما یک درخواست واردسازی داشتید. ممکن است زمان ببرد."
|
|
||||||
lists: "فهرست ها"
|
|
||||||
noLists: "فهرستی موجود نیست"
|
|
||||||
note: "یادداشت"
|
|
||||||
notes: "یادداشت ها"
|
|
||||||
following: "دنبال میکنید"
|
|
||||||
followers: "دنبال کنندگان"
|
|
||||||
followsYou: "شما را دنبال می کند"
|
|
||||||
createList: "ایجاد فهرست"
|
|
||||||
manageLists: "مدیریت فهرست ها"
|
|
||||||
error: "خطا"
|
|
||||||
somethingHappened: "یک مشکلی پیش آمده"
|
|
||||||
retry: "تلاش مجدد"
|
|
||||||
pageLoadError: "در هنگام بارگیری صفحه یک مشکلی به وجود آمده"
|
|
||||||
pageLoadErrorDescription: "این معمولا به خاطر مشکل در شبکه و یا حافظه پنهان اتفاق می افتد. سعی کنید حافظه پنهان را خالی کنید و یا مدتی صبر کنید و دوباره امتحان کنید."
|
|
||||||
serverIsDead: "سرور پاسخگو نیست. لطفا مدتی صبر کنید و دوباره تلاش کنید."
|
|
||||||
youShouldUpgradeClient: "برای دیدن این صفحه، لطفا صفحه را تازهکنید و از نسخه جدیدتر نرمافزار استفاده کنید."
|
|
||||||
enterListName: "نامی برای فهرست وارد کنید"
|
|
||||||
privacy: "حریم خصوصی"
|
|
||||||
makeFollowManuallyApprove: "درخواست دنبال کردن فقط با تایید کاربر مجاز است"
|
|
||||||
defaultNoteVisibility: "پیشفرض نمایش یادداشت"
|
|
||||||
follow: "دنبال میکنید"
|
|
||||||
followRequest: "درخواست های دنبالکردن"
|
|
||||||
followRequests: "درخواستهای دنبالکردن"
|
|
||||||
unfollow: "لغو دنبالکردن"
|
|
||||||
followRequestPending: "منتظر برای تایید درخواست دنبالکردن"
|
|
||||||
enterEmoji: "یک شکلک وارد کنید"
|
|
||||||
renote: "بازنشر"
|
|
||||||
unrenote: "حذف بازنشر"
|
|
||||||
renoted: "بازنشر شده."
|
|
||||||
renotedToX: "به {name} بازنشر شده."
|
|
||||||
cantRenote: "این پیام را نمی توان بازنشر کرد."
|
|
||||||
cantReRenote: "نمی شود یک بازنشر شده را دوباره بازنشر کرد."
|
|
||||||
quote: "نقلقول"
|
|
||||||
inChannelRenote: "بازنشر فقط در کانال"
|
|
||||||
inChannelQuote: "نقلقول فقط در کانال"
|
|
||||||
renoteToChannel: "بازنشر در کانال"
|
|
||||||
renoteToOtherChannel: "بازنشر در کانالهای دیگر"
|
|
||||||
pinnedNote: "یادداشت سنجاق شده"
|
|
||||||
pinned: "سنجاق کردن"
|
|
||||||
you: "شما"
|
|
||||||
clickToShow: "کلیک به جهت نمایش"
|
|
||||||
sensitive: "محتوای حساس"
|
|
||||||
add: "اضافه"
|
|
||||||
reaction: "واکنشها"
|
|
||||||
reactions: "واکنشها"
|
|
||||||
emojiPicker: "شکلک انتخابکن"
|
|
||||||
reactionSettingDescription2: "برای مرتب کردن بکشید، برای حذف کلیک کنید، برای اضافه کردن بروی \"+\" کلیک کنید."
|
|
||||||
rememberNoteVisibility: "تنظیمات نمایش یادداشتها را به خاطر بسپر"
|
|
||||||
attachCancel: "حذف ضمیمه"
|
|
||||||
deleteFile: "حذف پرونده"
|
|
||||||
markAsSensitive: "علامت به عنوان حساس"
|
|
||||||
unmarkAsSensitive: "برداشتن علامت حساس"
|
|
||||||
enterFileName: "نام پرونده را وارد کن"
|
|
||||||
mute: "سکوت"
|
|
||||||
unmute: "لغو سکوت"
|
|
||||||
renoteMute: "سکوت بازنشر"
|
|
||||||
renoteUnmute: "لغو سکوت بازنشر"
|
|
||||||
block: "مسدود کردن"
|
|
||||||
unblock: "رفع مسدودیت"
|
|
||||||
suspend: "انجماد"
|
|
||||||
unsuspend: "ذوب شدن"
|
|
||||||
blockConfirm: "آیا می خواهید این حساب مسدود شود؟"
|
|
||||||
unblockConfirm: "آیا می خواهید مسدودیت این حساب رفع شود؟"
|
|
||||||
suspendConfirm: "آیا می خواهید منجمد شود؟"
|
|
||||||
unsuspendConfirm: "آیا می خواهید یخ زدایی شود؟"
|
|
||||||
selectList: "انتخاب یک فهرست"
|
|
||||||
editList: "ویرایش فهرست"
|
|
||||||
selectChannel: "انتخاب یک کانال"
|
|
||||||
selectAntenna: "انتخاب یک آنتن"
|
|
||||||
editAntenna: "ویرایش آنتن"
|
|
||||||
createAntenna: "ایجاد یک آنتن"
|
|
||||||
selectWidget: "انتخاب یک ابزارک"
|
|
||||||
editWidgets: "ویرایش یک ابزارک"
|
|
||||||
editWidgetsExit: "پایان ویرایش"
|
|
||||||
customEmojis: "شکلک سفارشی"
|
|
||||||
emoji: "شکلک"
|
|
||||||
emojis: "شکلک"
|
|
||||||
emojiName: "اسم شکلک"
|
|
||||||
emojiUrl: "پیوند شکلک"
|
|
||||||
addEmoji: "افزودن شکلک"
|
|
||||||
settingGuide: "تنظیمات پیشنهادی"
|
|
||||||
flagAsBot: "این اکانت را به عنوان ربات علامت گذاری کن"
|
|
||||||
flagAsBotDescription: "این قابلیت را اگه این اکانت توسط یک ربات کنترل می شود فعال کنید. در صورت فعال شدن، این به عنوان یک پرچم برای برنامه نویس های دیگر عمل می کند تا جلوی تعامل های بی پایان با ربات های دیگر را بگیرد و همچنین سیستم داخلی Misskey با این اکانت به صورت ربات رفتار میکند."
|
|
||||||
flagAsCat: "میووووووووووووووووو!!!!!!!!!"
|
|
||||||
flagAsCatDescription: "میو میو میو؟؟"
|
|
||||||
flagShowTimelineReplies: "نمایش پاسخ های یادداشت به صورت خط زمانی"
|
|
||||||
loginFailed: "مشکل در وارد شدن"
|
|
||||||
chooseServerOnMisskeyHub: "یک سرور را از Misskey Hub انتخاب کنید"
|
|
||||||
specifyServerHost: "یک سرور را به طور مستقیم تعیین کنید"
|
|
||||||
inputHostName: "دامنه را وارد کنید"
|
|
||||||
general: "عمومی"
|
|
||||||
wallpaper: "تصویر پسزمینه"
|
|
||||||
setWallpaper: "تنظیم تصویر پسزمینه"
|
|
||||||
removeWallpaper: "حذف تصویر پسزمینه"
|
|
||||||
searchWith: "جستجو: {q}"
|
|
||||||
youHaveNoLists: "شما فهرستی ندارید"
|
|
||||||
followConfirm: "آیا می خواهید {name} را دنبال کنید؟"
|
|
||||||
proxyAccount: "حساب وکیل"
|
|
||||||
instances: "سرور"
|
|
||||||
cpuAndMemory: "پردازنده و حافظه رم"
|
|
||||||
network: "شبکه"
|
|
||||||
disk: "حافظه ذخیره"
|
|
||||||
instanceInfo: "اطلاعات سرور"
|
|
||||||
remove: "حذف"
|
|
||||||
pinnedNotes: "یادداشت سنجاق شده"
|
|
||||||
userList: "فهرست ها"
|
|
||||||
smtpUser: "نام کاربری"
|
|
||||||
smtpPass: "گذرواژه"
|
|
||||||
user: "کاربران"
|
|
||||||
searchByGoogle: "جستجو"
|
|
||||||
file: "پرونده ها"
|
|
||||||
replies: "پاسخ"
|
|
||||||
renotes: "بازنشر"
|
|
||||||
_email:
|
|
||||||
_follow:
|
|
||||||
title: "شما دنبال شده اید"
|
|
||||||
_theme:
|
|
||||||
keys:
|
|
||||||
mention: "اشاره کردن"
|
|
||||||
renote: "بازنشر"
|
|
||||||
_sfx:
|
|
||||||
note: "یادداشت ها"
|
|
||||||
notification: "اعلان ها"
|
|
||||||
_2fa:
|
|
||||||
renewTOTPCancel: "الان نه"
|
|
||||||
_widgets:
|
|
||||||
profile: "نمایه"
|
|
||||||
instanceInfo: "اطلاعات سرور"
|
|
||||||
notifications: "اعلان ها"
|
|
||||||
timeline: "نوار زمانی"
|
|
||||||
_userList:
|
|
||||||
chooseList: "انتخاب یک فهرست"
|
|
||||||
_cw:
|
|
||||||
show: "بارگیری بیشتر"
|
|
||||||
_visibility:
|
|
||||||
followers: "دنبال کنندگان"
|
|
||||||
specified: "یادداشت مستقیم"
|
|
||||||
_profile:
|
|
||||||
username: "نام کاربری"
|
|
||||||
_exportOrImport:
|
|
||||||
followingList: "دنبال میکنید"
|
|
||||||
muteList: "سکوت"
|
|
||||||
blockingList: "مسدود کردن"
|
|
||||||
userLists: "فهرست ها"
|
|
||||||
_notification:
|
|
||||||
youWereFollowed: "شما دنبال شده اید"
|
|
||||||
_types:
|
|
||||||
follow: "دنبال میکنید"
|
|
||||||
mention: "اشاره کردن"
|
|
||||||
renote: "بازنشر"
|
|
||||||
quote: "نقلقول"
|
|
||||||
reaction: "واکنشها"
|
|
||||||
login: "ورود"
|
|
||||||
_actions:
|
|
||||||
reply: "پاسخ"
|
|
||||||
renote: "بازنشر"
|
|
||||||
_deck:
|
|
||||||
_columns:
|
|
||||||
notifications: "اعلان ها"
|
|
||||||
tl: "نوار زمانی"
|
|
||||||
list: "فهرست ها"
|
|
||||||
mentions: "اشاره کردن"
|
|
||||||
direct: "یادداشت مستقیم"
|
|
||||||
_moderationLogTypes:
|
|
||||||
suspend: "انجماد"
|
|
||||||
@@ -619,8 +619,6 @@ output: "輸出"
|
|||||||
script: "腳本"
|
script: "腳本"
|
||||||
disablePagesScript: "停用頁面的 AiScript 腳本"
|
disablePagesScript: "停用頁面的 AiScript 腳本"
|
||||||
updateRemoteUser: "更新遠端使用者資訊"
|
updateRemoteUser: "更新遠端使用者資訊"
|
||||||
unsetMfa: "解除雙重驗證"
|
|
||||||
unsetMfaConfirm: "要解除雙重驗證嗎?"
|
|
||||||
unsetUserAvatar: "移除使用者的大頭貼"
|
unsetUserAvatar: "移除使用者的大頭貼"
|
||||||
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
|
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
|
||||||
unsetUserBanner: "移除使用者的橫幅圖像"
|
unsetUserBanner: "移除使用者的橫幅圖像"
|
||||||
@@ -1419,8 +1417,6 @@ addToEmojiPalette: "增加表情符號調色盤"
|
|||||||
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
|
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
|
||||||
append: "加在最後"
|
append: "加在最後"
|
||||||
prepend: "加在前面"
|
prepend: "加在前面"
|
||||||
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
|
|
||||||
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
|
|
||||||
_imageEditing:
|
_imageEditing:
|
||||||
_vars:
|
_vars:
|
||||||
caption: "檔案標題"
|
caption: "檔案標題"
|
||||||
@@ -2153,15 +2149,6 @@ _sensitiveMediaDetection:
|
|||||||
setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。"
|
setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。"
|
||||||
analyzeVideos: "啟用影片分析"
|
analyzeVideos: "啟用影片分析"
|
||||||
analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。"
|
analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。"
|
||||||
externalServiceInfo: "敏感媒體的判定已分離至外部服務(sensitive-detector)。若要使用此功能,必須另外設定 Sidecar 服務,並設定下方的連接資訊。若未設定連接資訊,則不會執行判定(視為非敏感內容)。"
|
|
||||||
apiUrl: "判定服務的連接資訊 URL"
|
|
||||||
apiUrlDescription: "sensitive-detector 服務的基礎網址(例如:http://localhost:3009)。若要連接位於私有網路上的服務,請在設定檔的 allowedPrivateNetworks 中允許對應的連接網路。若使用代理伺服器(Proxy),也請一併設定 proxyBypassHosts。若留白,則不會執行敏感內容判定。"
|
|
||||||
apiKey: "API 金鑰"
|
|
||||||
apiKeyDescription: "若判定服務端已設定認證(Bearer Token),請輸入此資訊。若未設定,請保持空白。"
|
|
||||||
timeout: "連線逾時(微秒)"
|
|
||||||
timeoutDescription: "單次判定請求的逾時時間。"
|
|
||||||
maxImagesPerRequest: "每筆請求的最大圖片數量"
|
|
||||||
maxImagesPerRequestDescription: "當判定包含多個影格的內容(如影片等)時,為每筆請求所允許的單次最大圖片數量。超過此數量的部分將會分割並依序發送。請確保此數值不超過 sensitive-detector 端的 maxParts 設定(預設值:10)。若超過該數值,該區塊(Chunk)的所有項目將被視為非敏感內容。"
|
|
||||||
_emailUnavailable:
|
_emailUnavailable:
|
||||||
used: "已被使用"
|
used: "已被使用"
|
||||||
format: "格式無效"
|
format: "格式無效"
|
||||||
@@ -2474,7 +2461,6 @@ _permissions:
|
|||||||
"read:admin:show-moderation-log": "查看審查紀錄"
|
"read:admin:show-moderation-log": "查看審查紀錄"
|
||||||
"read:admin:show-user": "查看使用者的私密資訊"
|
"read:admin:show-user": "查看使用者的私密資訊"
|
||||||
"write:admin:suspend-user": "凍結使用者"
|
"write:admin:suspend-user": "凍結使用者"
|
||||||
"write:admin:unset-mfa": "解除使用者的雙重驗證"
|
|
||||||
"write:admin:unset-user-avatar": "刪除使用者的頭像"
|
"write:admin:unset-user-avatar": "刪除使用者的頭像"
|
||||||
"write:admin:unset-user-banner": "刪除使用者的橫幅"
|
"write:admin:unset-user-banner": "刪除使用者的橫幅"
|
||||||
"write:admin:unsuspend-user": "解除凍結使用者"
|
"write:admin:unsuspend-user": "解除凍結使用者"
|
||||||
@@ -2990,7 +2976,6 @@ _moderationLogTypes:
|
|||||||
createAvatarDecoration: "建立頭像裝飾"
|
createAvatarDecoration: "建立頭像裝飾"
|
||||||
updateAvatarDecoration: "更新頭像裝飾"
|
updateAvatarDecoration: "更新頭像裝飾"
|
||||||
deleteAvatarDecoration: "刪除頭像裝飾"
|
deleteAvatarDecoration: "刪除頭像裝飾"
|
||||||
unsetMfa: "解除使用者的雙重驗證"
|
|
||||||
unsetUserAvatar: "移除使用者的大頭貼"
|
unsetUserAvatar: "移除使用者的大頭貼"
|
||||||
unsetUserBanner: "移除使用者的橫幅圖像"
|
unsetUserBanner: "移除使用者的橫幅圖像"
|
||||||
createSystemWebhook: "建立 SystemWebhook"
|
createSystemWebhook: "建立 SystemWebhook"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2026.7.0",
|
"version": "2026.7.0-rc.0",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
@@ -130,6 +131,9 @@ export class AiService {
|
|||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
|
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (let i = 0; i < chunk.length; i++) {
|
for (let i = 0; i < chunk.length; i++) {
|
||||||
@@ -144,13 +148,14 @@ export class AiService {
|
|||||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.httpRequestService.send(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: form,
|
body: form,
|
||||||
timeout,
|
// 外部サービスとして通常の proxy / private address 制限を適用する。
|
||||||
}, {
|
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
|
||||||
throwErrorWhenResponseNotOk: false,
|
agent: (u) => this.httpRequestService.getAgentByUrl(u),
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -176,6 +181,8 @@ export class AiService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
|
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
return chunk.map(() => null);
|
return chunk.map(() => null);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
|
|||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
import type { IObject } from '@/core/activitypub/type.js';
|
import type { IObject } from '@/core/activitypub/type.js';
|
||||||
import type { BodyInit, Response } from 'node-fetch';
|
import type { Response } from 'node-fetch';
|
||||||
import type { URL } from 'node:url';
|
import type { URL } from 'node:url';
|
||||||
|
|
||||||
export type HttpRequestSendOptions = {
|
export type HttpRequestSendOptions = {
|
||||||
@@ -311,7 +311,7 @@ export class HttpRequestService {
|
|||||||
url: string,
|
url: string,
|
||||||
args: {
|
args: {
|
||||||
method?: string,
|
method?: string,
|
||||||
body?: BodyInit,
|
body?: string,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
size?: number,
|
size?: number,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export class PrettyConsoleBackend implements LogBackend {
|
|||||||
presentationLevel === 'debug' ? chalk.gray(record.message) :
|
presentationLevel === 'debug' ? chalk.gray(record.message) :
|
||||||
presentationLevel === 'info' ? record.message :
|
presentationLevel === 'info' ? record.message :
|
||||||
null;
|
null;
|
||||||
// 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。
|
// 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。
|
||||||
const worker = record.isPrimary ? '*' : record.workerId;
|
const worker = record.isPrimary ? '*' : record.workerId;
|
||||||
|
|
||||||
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
|
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ volumes
|
|||||||
docker.env
|
docker.env
|
||||||
*.test.conf
|
*.test.conf
|
||||||
*.test.default.yml
|
*.test.default.yml
|
||||||
*.test.config.json
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function generate {
|
|||||||
-days 500
|
-days 500
|
||||||
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
|
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
|
||||||
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
|
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
|
||||||
if [ ! -f .config/$1.config.json ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
|
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
|
||||||
}
|
}
|
||||||
|
|
||||||
generate a.test
|
generate a.test
|
||||||
|
|||||||
@@ -4,12 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js';
|
||||||
import type { MiMeta } from '@/models/_.js';
|
import type { MiMeta } from '@/models/_.js';
|
||||||
import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import type { LoggerService } from '@/core/LoggerService.js';
|
import type { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { AiService, type Prediction } from '@/core/AiService.js';
|
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と
|
||||||
|
// レスポンス解釈を検証する。
|
||||||
|
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
||||||
|
vi.mock('node-fetch', () => ({ default: fetchMock }));
|
||||||
|
|
||||||
|
const { AiService } = await import('@/core/AiService.js');
|
||||||
|
|
||||||
|
let getAgentByUrlMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const DEFAULT_META = {
|
const DEFAULT_META = {
|
||||||
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
|
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
|
||||||
@@ -18,9 +25,10 @@ const DEFAULT_META = {
|
|||||||
sensitiveMediaDetectionMaxImagesPerRequest: 4,
|
sensitiveMediaDetectionMaxImagesPerRequest: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
|
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType {
|
||||||
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
|
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
|
||||||
const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
|
getAgentByUrlMock = vi.fn(() => undefined);
|
||||||
|
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
|
||||||
const loggerService = {
|
const loggerService = {
|
||||||
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
||||||
} as unknown as LoggerService;
|
} as unknown as LoggerService;
|
||||||
@@ -44,11 +52,11 @@ const buf = (s: string) => Buffer.from(s);
|
|||||||
|
|
||||||
describe('AiService', () => {
|
describe('AiService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sendMock.mockReset();
|
fetchMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('正常: 送信順を保った予測値配列を返す', async () => {
|
test('正常: 送信順を保った予測値配列を返す', async () => {
|
||||||
sendMock.mockResolvedValue(okResponse([
|
fetchMock.mockResolvedValue(okResponse([
|
||||||
{ success: true, predictions: neutral() },
|
{ success: true, predictions: neutral() },
|
||||||
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
|
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
|
||||||
]));
|
]));
|
||||||
@@ -58,35 +66,30 @@ describe('AiService', () => {
|
|||||||
[{ className: 'Neutral', probability: 0.99 }],
|
[{ className: 'Neutral', probability: 0.99 }],
|
||||||
[{ className: 'Porn', probability: 0.8 }],
|
[{ className: 'Porn', probability: 0.8 }],
|
||||||
]);
|
]);
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('外部サービス: HttpRequestService を使用する', async () => {
|
test('外部サービス: 通常の outbound agent を使用する', async () => {
|
||||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
|
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
|
||||||
|
|
||||||
await svc.detectSensitiveMany([buf('a')]);
|
await svc.detectSensitiveMany([buf('a')]);
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
|
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown };
|
||||||
method: 'POST',
|
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images'));
|
||||||
headers: {},
|
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL));
|
||||||
body: expect.any(FormData),
|
|
||||||
timeout: 5000,
|
|
||||||
}, {
|
|
||||||
throwErrorWhenResponseNotOk: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
|
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
|
||||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||||
const svc = makeService();
|
const svc = makeService();
|
||||||
const res = await svc.detectSensitive(buf('a'));
|
const res = await svc.detectSensitive(buf('a'));
|
||||||
expect(res).toEqual(neutral());
|
expect(res).toEqual(neutral());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('部分失敗: 失敗パーツのみ null になる', async () => {
|
test('部分失敗: 失敗パーツのみ null になる', async () => {
|
||||||
sendMock.mockResolvedValue(okResponse([
|
fetchMock.mockResolvedValue(okResponse([
|
||||||
{ success: true, predictions: neutral() },
|
{ success: true, predictions: neutral() },
|
||||||
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
|
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
|
||||||
]));
|
]));
|
||||||
@@ -97,14 +100,14 @@ describe('AiService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('非200: チャンク全件 null(例外を投げない)', async () => {
|
test('非200: チャンク全件 null(例外を投げない)', async () => {
|
||||||
sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
|
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
|
||||||
const svc = makeService();
|
const svc = makeService();
|
||||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||||
expect(res).toEqual([null, null]);
|
expect(res).toEqual([null, null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
|
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
|
||||||
sendMock.mockRejectedValue(new Error('network down'));
|
fetchMock.mockRejectedValue(new Error('network down'));
|
||||||
const svc = makeService();
|
const svc = makeService();
|
||||||
const res = await svc.detectSensitiveMany([buf('a')]);
|
const res = await svc.detectSensitiveMany([buf('a')]);
|
||||||
expect(res).toEqual([null]);
|
expect(res).toEqual([null]);
|
||||||
@@ -114,11 +117,11 @@ describe('AiService', () => {
|
|||||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
|
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
|
||||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||||
expect(res).toEqual([null, null]);
|
expect(res).toEqual([null, null]);
|
||||||
expect(sendMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
|
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
|
||||||
sendMock.mockResolvedValue(okResponse([
|
fetchMock.mockResolvedValue(okResponse([
|
||||||
{ success: true, predictions: neutral() },
|
{ success: true, predictions: neutral() },
|
||||||
{ success: true, predictions: neutral() },
|
{ success: true, predictions: neutral() },
|
||||||
{ success: true, predictions: neutral() },
|
{ success: true, predictions: neutral() },
|
||||||
@@ -127,24 +130,22 @@ describe('AiService', () => {
|
|||||||
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
|
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
|
||||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
|
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
|
||||||
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
|
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
|
||||||
expect(sendMock).toHaveBeenCalledTimes(3);
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
expect(res).toHaveLength(5);
|
expect(res).toHaveLength(5);
|
||||||
expect(res.every(x => x != null)).toBe(true);
|
expect(res.every(x => x != null)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
|
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
|
||||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||||
|
|
||||||
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
|
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
|
||||||
await withKey.detectSensitiveMany([buf('a')]);
|
await withKey.detectSensitiveMany([buf('a')]);
|
||||||
const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret');
|
||||||
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
|
|
||||||
|
|
||||||
sendMock.mockClear();
|
fetchMock.mockClear();
|
||||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||||
const withoutKey = makeService();
|
const withoutKey = makeService();
|
||||||
await withoutKey.detectSensitiveMany([buf('a')]);
|
await withoutKey.detectSensitiveMany([buf('a')]);
|
||||||
const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined();
|
||||||
expect(withoutKeyHeaders.Authorization).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,14 +27,6 @@ export function extractDomain(url: string) {
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryParseUrl(url: string | URL, base?: string | URL): URL | null {
|
|
||||||
try {
|
|
||||||
return new URL(url, base);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maybeMakeRelative(urlStr: string, baseStr: string): string {
|
export function maybeMakeRelative(urlStr: string, baseStr: string): string {
|
||||||
try {
|
try {
|
||||||
const baseObj = new URL(baseStr);
|
const baseObj = new URL(baseStr);
|
||||||
|
|||||||
@@ -83,16 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
v-else-if="content.type === 'video'"
|
v-else-if="content.type === 'video'"
|
||||||
ref="videoEl"
|
ref="videoEl"
|
||||||
data-gallery-click-action="video"
|
data-gallery-click-action="video"
|
||||||
:class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
|
:class="$style.content"
|
||||||
:src="content.url"
|
:src="content.url"
|
||||||
:alt="content.file?.comment ?? undefined"
|
:alt="content.file?.comment ?? undefined"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
|
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
|
||||||
playsinline
|
playsinline
|
||||||
@loadedmetadata="onVideoLoadedMetadata"
|
@loadedmetadata="originalContentLoaded = true"
|
||||||
@click.stop="onVideoClick"
|
@click.stop="onVideoClick"
|
||||||
></video>
|
></video>
|
||||||
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :class="$style.playIconWrapper">
|
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" data-gallery-click-action="video" :class="$style.playIconWrapper">
|
||||||
<div :class="$style.playIcon">
|
<div :class="$style.playIcon">
|
||||||
<i class="ti ti-player-play"></i>
|
<i class="ti ti-player-play"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,21 +224,6 @@ const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
|
|||||||
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
|
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
|
||||||
let canOpenAnimation = false;
|
let canOpenAnimation = false;
|
||||||
|
|
||||||
const videoAspectRatio = ref<number | null>(
|
|
||||||
props.content.width != null && props.content.height != null && props.content.width > 0 && props.content.height > 0
|
|
||||||
? props.content.width / props.content.height
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
function onVideoLoadedMetadata() {
|
|
||||||
originalContentLoaded.value = true;
|
|
||||||
|
|
||||||
// ドライブ上のメタデータが無い場合に限り、動画自体の初期サイズから縦横比を確定させる
|
|
||||||
if (videoAspectRatio.value != null) return;
|
|
||||||
if (videoEl.value == null || videoEl.value.videoWidth === 0 || videoEl.value.videoHeight === 0) return;
|
|
||||||
videoAspectRatio.value = videoEl.value.videoWidth / videoEl.value.videoHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerSize = 30;
|
const headerSize = 30;
|
||||||
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
|
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
|
||||||
|
|
||||||
@@ -956,25 +941,6 @@ defineExpose({
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video {
|
|
||||||
display: block;
|
|
||||||
user-select: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
translate: -50% -50%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.videoSized {
|
|
||||||
width: min(100cqw, calc(100cqh * v-bind("videoAspectRatio ?? 16 / 9")));
|
|
||||||
height: auto;
|
|
||||||
background-color: #000;
|
|
||||||
aspect-ratio: v-bind("videoAspectRatio ?? 16 / 9");
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -1003,8 +969,6 @@ defineExpose({
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
// .videoSizedが使う100cqw / 100cqhの基準 (= paddingを除いた実際の表示領域)
|
|
||||||
container-type: size;
|
|
||||||
transition: scale 200ms ease, opacity 200ms ease !important;
|
transition: scale 200ms ease, opacity 200ms ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,7 +985,6 @@ defineExpose({
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playIcon {
|
.playIcon {
|
||||||
@@ -1037,8 +1000,8 @@ defineExpose({
|
|||||||
transition: scale 100ms ease;
|
transition: scale 100ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// アイコン自体はクリックを受け取らないので、hoverは下のvideo要素を経由して拾う
|
.playIconWrapper:hover .playIcon,
|
||||||
.video:hover ~ .playIconWrapper .playIcon {
|
.playIcon:hover {
|
||||||
scale: 1.2;
|
scale: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { ref } from 'vue';
|
|||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
import { url as baseUrl } from '@@/js/config.js';
|
import { url as baseUrl } from '@@/js/config.js';
|
||||||
import { tryParseUrl } from '@@/js/url.js';
|
|
||||||
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
||||||
import { shuffle } from '@/utility/shuffle.js';
|
import { shuffle } from '@/utility/shuffle.js';
|
||||||
|
|
||||||
@@ -57,8 +56,8 @@ const tick = () => {
|
|||||||
}
|
}
|
||||||
items.value = feed.items.filter((item) => {
|
items.value = feed.items.filter((item) => {
|
||||||
if (!item.link) return false;
|
if (!item.link) return false;
|
||||||
const itemUrl = tryParseUrl(item.link, baseUrl);
|
const itemUrl = new URL(item.link, baseUrl);
|
||||||
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
|
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||||
});
|
});
|
||||||
fetching.value = false;
|
fetching.value = false;
|
||||||
key.value++;
|
key.value++;
|
||||||
|
|||||||
@@ -24,11 +24,10 @@ import { ref, watch, computed } from 'vue';
|
|||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { url as base } from '@@/js/config.js';
|
import { url as base } from '@@/js/config.js';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
import { tryParseUrl } from '@@/js/url.js';
|
|
||||||
import { useWidgetPropsManager } from './widget.js';
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
import { i18n } from '@/i18n.js';
|
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
|
|
||||||
const name = 'rss';
|
const name = 'rss';
|
||||||
@@ -84,8 +83,8 @@ const tick = () => {
|
|||||||
.then((feed: Misskey.entities.FetchRssResponse) => {
|
.then((feed: Misskey.entities.FetchRssResponse) => {
|
||||||
rawItems.value = feed.items.filter((item) => {
|
rawItems.value = feed.items.filter((item) => {
|
||||||
if (!item.link) return false;
|
if (!item.link) return false;
|
||||||
const itemUrl = tryParseUrl(item.link, base);
|
const itemUrl = new URL(item.link, base);
|
||||||
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
|
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||||
});
|
});
|
||||||
fetching.value = false;
|
fetching.value = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,16 +29,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { url as base } from '@@/js/config.js';
|
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
|
||||||
import { tryParseUrl } from '@@/js/url.js';
|
|
||||||
import { useWidgetPropsManager } from './widget.js';
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
|
||||||
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
||||||
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import { shuffle } from '@/utility/shuffle.js';
|
import { shuffle } from '@/utility/shuffle.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { url as base } from '@@/js/config.js';
|
||||||
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
|
|
||||||
const name = 'rssTicker';
|
const name = 'rssTicker';
|
||||||
|
|
||||||
@@ -124,8 +123,8 @@ const tick = () => {
|
|||||||
.then((feed: Misskey.entities.FetchRssResponse) => {
|
.then((feed: Misskey.entities.FetchRssResponse) => {
|
||||||
rawItems.value = feed.items.filter((item) => {
|
rawItems.value = feed.items.filter((item) => {
|
||||||
if (!item.link) return false;
|
if (!item.link) return false;
|
||||||
const itemUrl = tryParseUrl(item.link, base);
|
const itemUrl = new URL(item.link, base);
|
||||||
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
|
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||||
});
|
});
|
||||||
fetching.value = false;
|
fetching.value = false;
|
||||||
key.value++;
|
key.value++;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
import { tryParseUrl } from '@@/js/url.js';
|
|
||||||
|
|
||||||
describe('tryParseUrl', () => {
|
|
||||||
test('parses an absolute URL', () => {
|
|
||||||
expect(tryParseUrl('https://example.com/path')?.href).toBe('https://example.com/path');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses a relative URL against a base URL', () => {
|
|
||||||
expect(tryParseUrl('/path', 'https://example.com/base')?.href).toBe('https://example.com/path');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for an invalid URL', () => {
|
|
||||||
expect(tryParseUrl('https://[invalid')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"name": "misskey-js",
|
"name": "misskey-js",
|
||||||
"version": "2026.7.0",
|
"version": "2026.7.0-rc.0",
|
||||||
"description": "Misskey SDK for JavaScript",
|
"description": "Misskey SDK for JavaScript",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "./built/index.js",
|
"main": "./built/index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user