Compare commits

...

13 Commits

Author SHA1 Message Date
syuilo e372196fd5 Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop 2026-08-02 17:45:57 +09:00
syuilo e5d8473977 New Crowdin updates (#17836)
* New translations ja-jp.yml (Chinese Traditional)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]

* New translations ja-jp.yml (Persian)

[ci skip]
2026-08-02 17:45:45 +09:00
syuilo 44a91bc022 fix(gh): improve test stability 2026-08-02 17:45:24 +09:00
かっこかり 1f97f9925c fix(gh): 意図的にnbspを使用している箇所をエスケープ表記に変更 (#17833) 2026-08-02 17:20:50 +09:00
かっこかり 1853898d71 fix(backend): スレッドミュートがすでに設定されている場合のエラーハンドリングを追加 (#17838)
* fix(backend): スレッドミュートがすでに設定されている場合のエラーハンドリングを追加

* Update Changelog
2026-08-02 17:19:54 +09:00
果物リン 3ce17c6e9f fix(backend): PageService に残っていたデバッグ用 console.log を削除 (#17834)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:28:42 +09:00
4ster1sk 4db251696d fix(test): setup.sh で $1.config.json の生成条件が $1.default.yml を参照していたのを修正 (#17826)
* fix(test): setup.sh で $1.config.json の生成条件が $1.default.yml を参照していたのを修正

* update .gitignore
2026-07-31 19:34:17 +09:00
github-actions[bot] d54a9dcd3e [skip ci] Update CHANGELOG.md (prepend template) 2026-07-31 07:48:04 +00:00
github-actions[bot] 8ea4a0ecac Release: 2026.7.0 2026-07-31 07:47:55 +00:00
おさむのひと 8dd59a8c06 fix: RSS系ウィジェットにフィードURL検査機構を追加 (#17831) 2026-07-31 16:11:39 +09:00
おさむのひと f74a303556 fix: sensitive-detectorへのリクエスト時もHttpRequestServiceを使うように (#17828) 2026-07-31 13:43:51 +09:00
おさむのひと 589d43b38b Update packages/backend/src/logging/PrettyConsoleBackend.ts
Co-authored-by: anatawa12 <anatawa12@icloud.com>
2026-07-30 21:31:42 +09:00
かっこかり bcc3bc4fd7 fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように (#17822)
* fix(frontend): MkLightboxの動画の縦横比を事前に確定させるように

* fix

* fix
2026-07-29 14:47:58 +09:00
28 changed files with 488 additions and 97 deletions
+1
View File
@@ -55,6 +55,7 @@ jobs:
# https://github.com/docker/compose/issues/1294#issuecomment-374847206 # https://github.com/docker/compose/issues/1294#issuecomment-374847206
run: | run: |
cd packages/backend/test-federation cd packages/backend/test-federation
touch ../../../built/._config_.json
docker compose up -d --scale tester=0 docker compose up -d --scale tester=0
- name: Print start_servers error - name: Print start_servers error
if: ${{ steps.start_servers.outcome == 'failure' }} if: ${{ steps.start_servers.outcome == 'failure' }}
+12
View File
@@ -1,3 +1,15 @@
## Unreleased
### General
-
### Client
-
### Server
- Fix: 既にミュートしているスレッドに対して再度スレッドミュートを作成しようとするとサーバーエラーになる問題を修正
## 2026.7.0 ## 2026.7.0
### Note ### Note
+293
View File
@@ -0,0 +1,293 @@
---
_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: "انتخابگر شکلک"
pinnedEmojisForReactionSettingDescription: "شما می توانید شکلک ها را به گونه ای تنظیم کنید که به صورت سنجاق شده هنگام واکنش دادن به نمایش در بیایند."
pinnedEmojisSettingDescription: "شما می توانید شکلک ها را به گونه ای تنظیم کنید که به صورت سنجاق شده هنگام واردکردن شکلک ها به نمایش در بیایند."
emojiPickerDisplay: "نمایشگر انتخابگر شکلک ها"
overwriteFromPinnedEmojisForReaction: "باطل کردن تنظیمات واکنش ها"
overwriteFromPinnedEmojis: "باطل کردن تنظیمات عمومی (شکلک ها)"
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: "تنظیمات پیشنهادی"
cacheRemoteFiles: "کش کردن پرونده های خارجی"
cacheRemoteFilesDescription: "در صورت فعال کردن این قابلیت پرونده های خارجی (در سرور های دیگر) در فضای ذخیره این سرور بارگیری می شود. این کار سرعت نمایش تصاویر را افزایش می دهد، اما با این کار فضای داخلی سرور بیشتر اشغال می شود. مدت زمانی که هر کاربر می تواند از سرور خارجی کش کند، براساس محدودیت ظرفیت حافظه اعمال شده بر نقش اوست. اگر از این محدودیت فراتر رود پرونده های قدیمی حافظه پنهان (کش) حذف و تبدیل به لینک پیوند می شود. اگر این تنظیم غیرفعال باشد پرونده های خارجی از همان ابتدا به صورت لینک پیوند ذخیره می شود.\n"
youCanCleanRemoteFilesCache: "شما می توانید حافظه پنهان (کش) را با کلیک بر دکمه 🗑️ در پایین صفحه مدیریت پرونده ها حذف کنید."
cacheRemoteSensitiveFiles: "کش کردن پرونده های حساس سرور های خارجی"
cacheRemoteSensitiveFilesDescription: "با غیر فعال کردن این قابلیت پرونده هایی که علامت حساس خورده اند در سرور های خارجی به جای اینکه در حافظه پنهان ذخیره شوند، مستقیما به ان پیوند داده می شوند."
flagAsBot: "این اکانت را به عنوان ربات علامت گذاری کن"
flagAsBotDescription: "این قابلیت را اگه این اکانت توسط یک ربات کنترل می شود فعال کنید. در صورت فعال شدن، این به عنوان یک پرچم برای برنامه نویس های دیگر عمل می کند تا جلوی تعامل های بی پایان با ربات های دیگر را بگیرد و همچنین سیستم داخلی Misskey با این اکانت به صورت ربات رفتار می‌کند."
flagAsCat: "میووووووووووووووووو!!!!!!!!!"
flagAsCatDescription: "میو میو میو؟؟"
flagShowTimelineReplies: "نمایش پاسخ های یادداشت به صورت خط زمانی"
flagShowTimelineRepliesDescription: "وقتی این قابلیت فعال شود، نه تنها یادداشت های کاربران بلکه پاسخ های کاربران به یادداشت ها را در خط زمانی به نمایش می گذارد."
autoAcceptFollowed: "به صورت خودکار درخواست های دنبال کردن را از کاربرانی که دنبال می کنید را قبول کن"
addAccount: "افزودن حساب کاربری"
reloadAccountsList: "بارگذاری مجدد فهرست حساب ها"
loginFailed: "مشکل در وارد شدن"
showOnRemote: "دیدن از سرور‌خارجی"
continueOnRemote: "ادامه در سرورخارجی"
chooseServerOnMisskeyHub: "یک سرور را از Misskey Hub انتخاب کنید"
specifyServerHost: "یک سرور را به طور مستقیم تعیین کنید"
inputHostName: "دامنه را وارد کنید"
general: "عمومی"
wallpaper: "تصویر پس‌زمینه"
setWallpaper: "تنظیم تصویر پس‌زمینه"
removeWallpaper: "حذف تصویر پس‌زمینه"
searchWith: "جستجو: {q}"
youHaveNoLists: "شما فهرستی ندارید"
followConfirm: "آیا می خواهید {name} را دنبال کنید؟"
proxyAccount: "حساب وکیل"
host: "میزبان"
selectSelf: "انتخاب خودت"
selectUser: "انتخاب کاربر"
recipient: "گیرنده"
annotation: "حاشیه نویسی"
instances: "سرور"
registeredAt: "ثبت شده در"
latestRequestReceivedAt: "آخرین درخواست دریافت شده"
latestStatus: "جدیدترین وضعیت"
storageUsage: "حافظه مصرفی"
charts: "نمودارها"
perHour: "در ساعت"
cpuAndMemory: "پردازنده و حافظه رم"
network: "شبکه"
disk: "حافظه ذخیره"
instanceInfo: "اطلاعات سرور"
noCustomEmojis: "هیچ شکلکی نیست"
remove: "حذف"
pinnedNotes: "یادداشت سنجاق شده"
userList: "فهرست ها"
emojiStyle: "سبک شکلک"
chooseEmoji: "انتخاب یک شکلک"
smtpHost: "میزبان"
smtpUser: "نام کاربری"
smtpPass: "گذرواژه"
theKeywordWhenSearchingForCustomEmoji: "این کلمه کلیدی است که هنگام جستجو در میان شکلک های سفارشی به کار گرفته می شود."
user: "کاربران"
searchByGoogle: "جستجو"
file: "پرونده ها"
manageCustomEmojis: "مدیریت شکلک های سفارشی"
rolesThatCanBeUsedThisEmojiAsReaction: "نقش هایی که اجازه دارند تا از این شکلک برای واکنش استفاده بکنند"
rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "اگر نقشی مشخص نشود، هر کسی اجازه استفاده از این شکلک به عنوان واکنش را دارا می باشند."
rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "این نقش ها باید عمومی باشند."
replies: "پاسخ"
renotes: "بازنشر"
_role:
_options:
canManageCustomEmojis: "مدیریت شکلک های سفارشی"
_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: "انجماد"
+15
View File
@@ -619,6 +619,8 @@ output: "輸出"
script: "腳本" script: "腳本"
disablePagesScript: "停用頁面的 AiScript 腳本" disablePagesScript: "停用頁面的 AiScript 腳本"
updateRemoteUser: "更新遠端使用者資訊" updateRemoteUser: "更新遠端使用者資訊"
unsetMfa: "解除雙重驗證"
unsetMfaConfirm: "要解除雙重驗證嗎?"
unsetUserAvatar: "移除使用者的大頭貼" unsetUserAvatar: "移除使用者的大頭貼"
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?" unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
unsetUserBanner: "移除使用者的橫幅圖像" unsetUserBanner: "移除使用者的橫幅圖像"
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "增加表情符號調色盤"
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?" emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
append: "加在最後" append: "加在最後"
prepend: "加在前面" prepend: "加在前面"
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
_imageEditing: _imageEditing:
_vars: _vars:
caption: "檔案標題" caption: "檔案標題"
@@ -2149,6 +2153,15 @@ _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: "格式無效"
@@ -2461,6 +2474,7 @@ _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": "解除凍結使用者"
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
createAvatarDecoration: "建立頭像裝飾" createAvatarDecoration: "建立頭像裝飾"
updateAvatarDecoration: "更新頭像裝飾" updateAvatarDecoration: "更新頭像裝飾"
deleteAvatarDecoration: "刪除頭像裝飾" deleteAvatarDecoration: "刪除頭像裝飾"
unsetMfa: "解除使用者的雙重驗證"
unsetUserAvatar: "移除使用者的大頭貼" unsetUserAvatar: "移除使用者的大頭貼"
unsetUserBanner: "移除使用者的橫幅圖像" unsetUserBanner: "移除使用者的橫幅圖像"
createSystemWebhook: "建立 SystemWebhook" createSystemWebhook: "建立 SystemWebhook"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2026.7.0-rc.0", "version": "2026.7.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -131,7 +131,7 @@ export function chunkMarkdownTable(
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [ const lines = [
'| Chunk | Base | Head | Δ | Δ (%) |', // nbspにすること '| Chunk | Base | Head | Δ | Δ\u00A0(%) |', // nbspにすること
'| --- | ---: | ---: | ---: | ---: |', '| --- | ---: | ---: | ---: | ---: |',
]; ];
if (total != null) { if (total != null) {
@@ -151,7 +151,7 @@ export function chunkMarkdownTable(
} }
if (hasGenerated) { if (hasGenerated) {
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること lines.push(`| (other\u00A0generated\u00A0chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること
} }
if (hasOther) { if (hasOther) {
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`); lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
@@ -177,8 +177,8 @@ test('renders the difference of independent medians instead of the paired median
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests'); const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
expect(row).toContain('100 <br> ± 0'); expect(row).toContain('100 <br> ±\u00A00');
expect(row).toContain('200 <br> ± 0'); expect(row).toContain('200 <br> ±\u00A00');
expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$'); expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$');
expect(row).toContain('| 0 |'); expect(row).toContain('| 0 |');
expect(row.split('|')).toHaveLength(7); expect(row.split('|')).toHaveLength(7);
@@ -251,7 +251,7 @@ test('renders a directional encoded byte delta at the absolute threshold', async
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network'); const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$'); expect(row).toContain('$\\color{orange}{\\text{+10\u00A0KB}}$');
}); });
test('colours absolute and relative deltas together when the row is significant', async () => { test('colours absolute and relative deltas together when the row is significant', async () => {
@@ -268,9 +268,9 @@ test('colours absolute and relative deltas together when the row is significant'
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network'); const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
expect(row).toContain('100 MB <br> ± 0 B'); expect(row).toContain('100\u00A0MB <br> ±\u00A00\u00A0B');
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$'); expect(row).toContain('$\\color{orange}{\\text{+20\u00A0KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
expect(row).toContain('| 0 B |'); expect(row).toContain('| 0\u00A0B |');
expect(row.split('|')).toHaveLength(7); expect(row.split('|')).toHaveLength(7);
}); });
@@ -33,7 +33,7 @@ export function formatNumber(value: number) {
} }
export function formatBytes(value: number) { export function formatBytes(value: number) {
if (value === 0) return '0 B'; // nbspにすること if (value === 0) return '0\u00A0B'; // nbspにすること
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
let unitIndex = 0; let unitIndex = 0;
let size = value; let size = value;
@@ -44,7 +44,7 @@ export function formatBytes(value: number) {
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; // nbspにすること return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))}\u00A0${units[unitIndex]}`; // nbspにすること
} }
/** /**
@@ -21,7 +21,7 @@ function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategor
function swatch(category: HeapSnapshotCategory) { function swatch(category: HeapSnapshotCategory) {
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label.replaceAll(' ', ' ')}**`; // nbspにすること return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$\u00A0**${heapSnapshotCategory[category].label.replaceAll(' ', '\u00A0')}**`; // nbspにすること
} }
/** /**
@@ -36,7 +36,7 @@ function formatMedian<T>(
const formatted = row.formatValue(value); const formatted = row.formatValue(value);
if (row.showMedianMad === false) return formatted; if (row.showMedianMad === false) return formatted;
// eslint-disable-next-line no-irregular-whitespace // eslint-disable-next-line no-irregular-whitespace
return `${formatted} <br> ± ${row.formatValue(spread)}`; // nbspにすること return `${formatted} <br> ±\u00A0${row.formatValue(spread)}`; // nbspにすること
} }
function formatDelta<T>( function formatDelta<T>(
@@ -20,15 +20,15 @@ import {
describe('formatBytes', () => { describe('formatBytes', () => {
// 1024ではなく1000区切り。単位が2桁以上なら小数を落とす // 1024ではなく1000区切り。単位が2桁以上なら小数を落とす
test.each([ test.each([
[0, '0 B'], [0, '0\u00A0B'],
[999, '999 B'], [999, '999\u00A0B'],
[1_000, '1 KB'], [1_000, '1\u00A0KB'],
[1_500, '1.5 KB'], [1_500, '1.5\u00A0KB'],
[15_000, '15 KB'], [15_000, '15\u00A0KB'],
[1_234_567, '1.2 MB'], [1_234_567, '1.2\u00A0MB'],
[12_345_678, '12 MB'], [12_345_678, '12\u00A0MB'],
[1_500_000_000, '1.5 GB'], [1_500_000_000, '1.5\u00A0GB'],
[1_500_000_000_000, '1,500 GB'], [1_500_000_000_000, '1,500\u00A0GB'],
])('formats %i as %s', (input, expected) => { ])('formats %i as %s', (input, expected) => {
expect(formatBytes(input)).toBe(expected); expect(formatBytes(input)).toBe(expected);
}); });
@@ -45,7 +45,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_100_000, 1_200_000, 1_300_000]), report([1_100_000, 1_200_000, 1_300_000]),
)); ));
expect(row).toContain('$\\text{+100 KB}$'); expect(row).toContain('$\\text{+100\u00A0KB}$');
expect(row).not.toContain('within noise'); expect(row).not.toContain('within noise');
expect(row).not.toContain('\\color{orange}'); expect(row).not.toContain('\\color{orange}');
}); });
@@ -56,7 +56,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_200_000, 1_200_000, 1_200_000]), report([1_200_000, 1_200_000, 1_200_000]),
)); ));
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$'); expect(row).toContain('$\\color{orange}{\\text{+200\u00A0KB}}$');
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$'); expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
expect(row).not.toContain('increase'); expect(row).not.toContain('increase');
}); });
@@ -67,7 +67,7 @@ describe('renderHeapSnapshotTable', () => {
report([1_050_000, 1_050_000, 1_050_000]), report([1_050_000, 1_050_000, 1_050_000]),
)); ));
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$'); expect(row).toContain('$\\text{+50\u00A0KB}$<br>$\\text{+5\\\\%}$');
expect(row.split('|')[4]).not.toContain('\\color{'); expect(row.split('|')[4]).not.toContain('\\color{');
}); });
@@ -33,7 +33,7 @@ describe('renderMetricComparisonTable', () => {
expect(table.split('\n')).toStrictEqual([ expect(table.split('\n')).toStrictEqual([
'| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |', '| Metric | @&nbsp;Base | @&nbsp;Head | Δ | MAD |',
'| --- | ---: | ---: | ---: | ---: |', '| --- | ---: | ---: | ---: | ---: |',
'| <strong>Metric</strong> | 100 units <br> ± 0 units | 120 units <br> ± 0 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |', '| <strong>Metric</strong> | 100 units <br> ±\u00A00 units | 120 units <br> ±\u00A00 units | $\\color{orange}{\\text{+20~units}}$<br>$\\color{orange}{\\text{+20\\\\%}}$ | 0 units |',
]); ]);
}); });
+4 -11
View File
@@ -4,7 +4,6 @@
*/ */
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';
@@ -131,9 +130,6 @@ 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++) {
@@ -148,14 +144,13 @@ export class AiService {
headers['Authorization'] = `Bearer ${apiKey}`; headers['Authorization'] = `Bearer ${apiKey}`;
} }
const res = await fetch(url, { const res = await this.httpRequestService.send(url, {
method: 'POST', method: 'POST',
headers, headers,
body: form, body: form,
// 外部サービスとして通常の proxy / private address 制限を適用する。 timeout,
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。 }, {
agent: (u) => this.httpRequestService.getAgentByUrl(u), throwErrorWhenResponseNotOk: false,
signal: controller.signal,
}); });
if (!res.ok) { if (!res.ok) {
@@ -181,8 +176,6 @@ 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 { Response } from 'node-fetch'; import type { BodyInit, 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?: string, body?: BodyInit,
headers?: Record<string, string>, headers?: Record<string, string>,
timeout?: number, timeout?: number,
size?: number, size?: number,
-2
View File
@@ -141,8 +141,6 @@ export class PageService {
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null), eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
}); });
console.log('page.content', page.content);
if (body.content != null) { if (body.content != null) {
const beforeReferencedNotes = this.collectReferencedNotes(page.content); const beforeReferencedNotes = this.collectReferencedNotes(page.content);
const afterReferencedNotes = this.collectReferencedNotes(body.content); const afterReferencedNotes = this.collectReferencedNotes(body.content);
@@ -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}`;
@@ -30,6 +30,12 @@ export const meta = {
code: 'NO_SUCH_NOTE', code: 'NO_SUCH_NOTE',
id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2', id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2',
}, },
alreadyMuting: {
message: 'You are already muting that thread.',
code: 'ALREADY_MUTING',
id: 'c146e22d-1141-4b31-b28d-176371014d18',
},
}, },
} as const; } as const;
@@ -59,14 +65,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err; throw err;
}); });
const _mutedNotes = await this.notesRepository.find({ // Check if already muting
where: [{ const exist = await this.noteThreadMutingsRepository.exists({
id: note.threadId ?? note.id, where: {
}, {
threadId: note.threadId ?? note.id, threadId: note.threadId ?? note.id,
}], userId: me.id,
},
}); });
if (exist) {
throw new ApiError(meta.errors.alreadyMuting);
}
await this.noteThreadMutingsRepository.insert({ await this.noteThreadMutingsRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
threadId: note.threadId ?? note.id, threadId: note.threadId ?? note.id,
@@ -4,3 +4,4 @@ volumes
docker.env docker.env
*.test.conf *.test.conf
*.test.default.yml *.test.default.yml
*.test.config.json
+1 -1
View File
@@ -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.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi if [ ! -f .config/$1.config.json ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi
} }
generate a.test generate a.test
+32 -33
View File
@@ -4,19 +4,12 @@
*/ */
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';
// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と const sendMock = vi.fn();
// レスポンス解釈を検証する。
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,
@@ -25,10 +18,9 @@ const DEFAULT_META = {
sensitiveMediaDetectionMaxImagesPerRequest: 4, sensitiveMediaDetectionMaxImagesPerRequest: 4,
}; };
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType { function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta; const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
getAgentByUrlMock = vi.fn(() => undefined); const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
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;
@@ -52,11 +44,11 @@ const buf = (s: string) => Buffer.from(s);
describe('AiService', () => { describe('AiService', () => {
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); sendMock.mockReset();
}); });
test('正常: 送信順を保った予測値配列を返す', async () => { test('正常: 送信順を保った予測値配列を返す', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.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 }] },
])); ]));
@@ -66,30 +58,35 @@ describe('AiService', () => {
[{ className: 'Neutral', probability: 0.99 }], [{ className: 'Neutral', probability: 0.99 }],
[{ className: 'Porn', probability: 0.8 }], [{ className: 'Porn', probability: 0.8 }],
]); ]);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
}); });
test('外部サービス: 通常の outbound agent を使用する', async () => { test('外部サービス: HttpRequestService を使用する', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.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')]);
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown }; expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images')); method: 'POST',
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL)); headers: {},
body: expect.any(FormData),
timeout: 5000,
}, {
throwErrorWhenResponseNotOk: false,
});
}); });
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => { test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.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 () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.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' } },
])); ]));
@@ -100,14 +97,14 @@ describe('AiService', () => {
}); });
test('非200: チャンク全件 null(例外を投げない)', async () => { test('非200: チャンク全件 null(例外を投げない)', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); sendMock.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 () => {
fetchMock.mockRejectedValue(new Error('network down')); sendMock.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]);
@@ -117,11 +114,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(fetchMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
}); });
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => { test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
fetchMock.mockResolvedValue(okResponse([ sendMock.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() },
@@ -130,22 +127,24 @@ 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(fetchMock).toHaveBeenCalledTimes(3); expect(sendMock).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 () => {
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.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')]);
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret'); const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
fetchMock.mockClear(); sendMock.mockClear();
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
const withoutKey = makeService(); const withoutKey = makeService();
await withoutKey.detectSensitiveMany([buf('a')]); await withoutKey.detectSensitiveMany([buf('a')]);
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined(); const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
expect(withoutKeyHeaders.Authorization).toBeUndefined();
}); });
}); });
+8
View File
@@ -27,6 +27,14 @@ 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.content" :class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
: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="originalContentLoaded = true" @loadedmetadata="onVideoLoadedMetadata"
@click.stop="onVideoClick" @click.stop="onVideoClick"
></video> ></video>
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" data-gallery-click-action="video" :class="$style.playIconWrapper"> <div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" :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,6 +224,21 @@ 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;
@@ -941,6 +956,25 @@ 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;
@@ -969,6 +1003,8 @@ 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;
} }
@@ -985,6 +1021,7 @@ defineExpose({
height: 100%; height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
pointer-events: none;
} }
.playIcon { .playIcon {
@@ -1000,8 +1037,8 @@ defineExpose({
transition: scale 100ms ease; transition: scale 100ms ease;
} }
.playIconWrapper:hover .playIcon, // hovervideo
.playIcon:hover { .video:hover ~ .playIconWrapper .playIcon {
scale: 1.2; scale: 1.2;
} }
@@ -31,6 +31,7 @@ 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';
@@ -56,8 +57,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 = new URL(item.link, baseUrl); const itemUrl = tryParseUrl(item.link, baseUrl);
return ['http:', 'https:'].includes(itemUrl.protocol); return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
}); });
fetching.value = false; fetching.value = false;
key.value++; key.value++;
+4 -3
View File
@@ -24,10 +24,11 @@ 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';
@@ -83,8 +84,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 = new URL(item.link, base); const itemUrl = tryParseUrl(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol); return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
}); });
fetching.value = false; fetching.value = false;
}); });
@@ -29,15 +29,16 @@ 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 MkMarqueeText from '@/components/MkMarqueeText.vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js'; import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkMarqueeText from '@/components/MkMarqueeText.vue';
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';
@@ -123,8 +124,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 = new URL(item.link, base); const itemUrl = tryParseUrl(item.link, base);
return ['http:', 'https:'].includes(itemUrl.protocol); return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
}); });
fetching.value = false; fetching.value = false;
key.value++; key.value++;
+21
View File
@@ -0,0 +1,21 @@
/*
* 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 -1
View File
@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2026.7.0-rc.0", "version": "2026.7.0",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",