mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-08-01 12:56:03 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 198eebaf4d |
@@ -1,15 +1,3 @@
|
||||
## Unreleased
|
||||
|
||||
### General
|
||||
-
|
||||
|
||||
### Client
|
||||
-
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
|
||||
## 2026.7.0
|
||||
|
||||
### Note
|
||||
|
||||
@@ -1,81 +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: "افزودن به فهرست"
|
||||
pinned: "سنجاق کردن"
|
||||
instances: "سرور"
|
||||
remove: "حذف"
|
||||
smtpUser: "نام کاربری"
|
||||
smtpPass: "گذرواژه"
|
||||
user: "کاربران"
|
||||
searchByGoogle: "جستجو"
|
||||
_sfx:
|
||||
notification: "اعلان ها"
|
||||
_2fa:
|
||||
renewTOTPCancel: "الان نه"
|
||||
_widgets:
|
||||
profile: "نمایه"
|
||||
notifications: "اعلان ها"
|
||||
timeline: "نوار زمانی"
|
||||
_profile:
|
||||
username: "نام کاربری"
|
||||
_notification:
|
||||
_types:
|
||||
login: "ورود"
|
||||
_deck:
|
||||
_columns:
|
||||
notifications: "اعلان ها"
|
||||
tl: "نوار زمانی"
|
||||
@@ -619,8 +619,6 @@ output: "輸出"
|
||||
script: "腳本"
|
||||
disablePagesScript: "停用頁面的 AiScript 腳本"
|
||||
updateRemoteUser: "更新遠端使用者資訊"
|
||||
unsetMfa: "解除雙重驗證"
|
||||
unsetMfaConfirm: "要解除雙重驗證嗎?"
|
||||
unsetUserAvatar: "移除使用者的大頭貼"
|
||||
unsetUserAvatarConfirm: "確定要移除使用者的大頭貼嗎?"
|
||||
unsetUserBanner: "移除使用者的橫幅圖像"
|
||||
@@ -1419,8 +1417,6 @@ addToEmojiPalette: "增加表情符號調色盤"
|
||||
emojiPaletteAlreadyAddedConfirm: "此表情符號在這個表情符號調色盤裡已經有了。確定要增加嗎?"
|
||||
append: "加在最後"
|
||||
prepend: "加在前面"
|
||||
urlPreviewSensitiveList: "限制縮圖顯示的 URL"
|
||||
urlPreviewSensitiveListDescription: "以空格指定為 AND,以換行指定為 OR。若以斜線(/)包圍則視為正規表達式。符合條件時,將不再顯示縮圖。"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "檔案標題"
|
||||
@@ -2153,15 +2149,6 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。"
|
||||
analyzeVideos: "啟用影片分析"
|
||||
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:
|
||||
used: "已被使用"
|
||||
format: "格式無效"
|
||||
@@ -2474,7 +2461,6 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "查看審查紀錄"
|
||||
"read:admin:show-user": "查看使用者的私密資訊"
|
||||
"write:admin:suspend-user": "凍結使用者"
|
||||
"write:admin:unset-mfa": "解除使用者的雙重驗證"
|
||||
"write:admin:unset-user-avatar": "刪除使用者的頭像"
|
||||
"write:admin:unset-user-banner": "刪除使用者的橫幅"
|
||||
"write:admin:unsuspend-user": "解除凍結使用者"
|
||||
@@ -2990,7 +2976,6 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "建立頭像裝飾"
|
||||
updateAvatarDecoration: "更新頭像裝飾"
|
||||
deleteAvatarDecoration: "刪除頭像裝飾"
|
||||
unsetMfa: "解除使用者的雙重驗證"
|
||||
unsetUserAvatar: "移除使用者的大頭貼"
|
||||
unsetUserBanner: "移除使用者的橫幅圖像"
|
||||
createSystemWebhook: "建立 SystemWebhook"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.7.0",
|
||||
"version": "2026.7.0-rc.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import fetch from 'node-fetch';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
@@ -130,6 +131,9 @@ export class AiService {
|
||||
|
||||
@bindThis
|
||||
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 {
|
||||
const form = new FormData();
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
@@ -144,13 +148,14 @@ export class AiService {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const res = await this.httpRequestService.send(url, {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
timeout,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: false,
|
||||
// 外部サービスとして通常の proxy / private address 制限を適用する。
|
||||
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
|
||||
agent: (u) => this.httpRequestService.getAgentByUrl(u),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -176,6 +181,8 @@ export class AiService {
|
||||
} catch (err) {
|
||||
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
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 { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.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';
|
||||
|
||||
export type HttpRequestSendOptions = {
|
||||
@@ -311,7 +311,7 @@ export class HttpRequestService {
|
||||
url: string,
|
||||
args: {
|
||||
method?: string,
|
||||
body?: BodyInit,
|
||||
body?: string,
|
||||
headers?: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
|
||||
@@ -73,7 +73,7 @@ export class PrettyConsoleBackend implements LogBackend {
|
||||
presentationLevel === 'debug' ? chalk.gray(record.message) :
|
||||
presentationLevel === 'info' ? record.message :
|
||||
null;
|
||||
// 主プロセスは「*」で示し、子プロセスはワーカー番号で識別します。
|
||||
// 主プロセスは従来どおり「*」、子プロセスはワーカー番号で識別します。
|
||||
const worker = record.isPrimary ? '*' : record.workerId;
|
||||
|
||||
let log = `${label} ${worker}\t[${contexts.join(' ')}]\t${message}`;
|
||||
|
||||
@@ -4,4 +4,3 @@ volumes
|
||||
docker.env
|
||||
*.test.conf
|
||||
*.test.default.yml
|
||||
*.test.config.json
|
||||
|
||||
@@ -28,7 +28,7 @@ function generate {
|
||||
-days 500
|
||||
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.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
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
*/
|
||||
|
||||
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 { HttpRequestService } from '@/core/HttpRequestService.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 = {
|
||||
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
|
||||
@@ -18,9 +25,10 @@ const DEFAULT_META = {
|
||||
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 httpRequestService = { send: sendMock } as unknown as HttpRequestService;
|
||||
getAgentByUrlMock = vi.fn(() => undefined);
|
||||
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
|
||||
const loggerService = {
|
||||
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
||||
} as unknown as LoggerService;
|
||||
@@ -44,11 +52,11 @@ const buf = (s: string) => Buffer.from(s);
|
||||
|
||||
describe('AiService', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
test('正常: 送信順を保った予測値配列を返す', async () => {
|
||||
sendMock.mockResolvedValue(okResponse([
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
|
||||
]));
|
||||
@@ -58,35 +66,30 @@ describe('AiService', () => {
|
||||
[{ className: 'Neutral', probability: 0.99 }],
|
||||
[{ className: 'Porn', probability: 0.8 }],
|
||||
]);
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
||||
});
|
||||
|
||||
test('外部サービス: HttpRequestService を使用する', async () => {
|
||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
test('外部サービス: 通常の outbound agent を使用する', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
|
||||
|
||||
await svc.detectSensitiveMany([buf('a')]);
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: expect.any(FormData),
|
||||
timeout: 5000,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: false,
|
||||
});
|
||||
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown };
|
||||
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images'));
|
||||
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL));
|
||||
});
|
||||
|
||||
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
|
||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitive(buf('a'));
|
||||
expect(res).toEqual(neutral());
|
||||
});
|
||||
|
||||
test('部分失敗: 失敗パーツのみ null になる', async () => {
|
||||
sendMock.mockResolvedValue(okResponse([
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
|
||||
]));
|
||||
@@ -97,14 +100,14 @@ describe('AiService', () => {
|
||||
});
|
||||
|
||||
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 res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res).toEqual([null, null]);
|
||||
});
|
||||
|
||||
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
|
||||
sendMock.mockRejectedValue(new Error('network down'));
|
||||
fetchMock.mockRejectedValue(new Error('network down'));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitiveMany([buf('a')]);
|
||||
expect(res).toEqual([null]);
|
||||
@@ -114,11 +117,11 @@ describe('AiService', () => {
|
||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res).toEqual([null, null]);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
|
||||
sendMock.mockResolvedValue(okResponse([
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
@@ -127,24 +130,22 @@ describe('AiService', () => {
|
||||
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
|
||||
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
|
||||
expect(sendMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(res).toHaveLength(5);
|
||||
expect(res.every(x => x != null)).toBe(true);
|
||||
});
|
||||
|
||||
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
|
||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
|
||||
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
|
||||
await withKey.detectSensitiveMany([buf('a')]);
|
||||
const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
|
||||
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret');
|
||||
|
||||
sendMock.mockClear();
|
||||
sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
const withoutKey = makeService();
|
||||
await withoutKey.detectSensitiveMany([buf('a')]);
|
||||
const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(withoutKeyHeaders.Authorization).toBeUndefined();
|
||||
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,14 +27,6 @@ export function extractDomain(url: string) {
|
||||
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 {
|
||||
try {
|
||||
const baseObj = new URL(baseStr);
|
||||
|
||||
@@ -83,16 +83,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
v-else-if="content.type === 'video'"
|
||||
ref="videoEl"
|
||||
data-gallery-click-action="video"
|
||||
:class="[$style.video, { [$style.videoSized]: videoAspectRatio != null }]"
|
||||
:class="$style.content"
|
||||
:src="content.url"
|
||||
:alt="content.file?.comment ?? undefined"
|
||||
draggable="false"
|
||||
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
|
||||
playsinline
|
||||
@loadedmetadata="onVideoLoadedMetadata"
|
||||
@loadedmetadata="originalContentLoaded = true"
|
||||
@click.stop="onVideoClick"
|
||||
></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">
|
||||
<i class="ti ti-player-play"></i>
|
||||
</div>
|
||||
@@ -224,21 +224,6 @@ const isVideoPlaying = computed(() => videoControl.value?.isPlaying ?? false);
|
||||
const isVideoActuallyPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? 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 footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
|
||||
|
||||
@@ -956,25 +941,6 @@ defineExpose({
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1003,8 +969,6 @@ defineExpose({
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// .videoSizedが使う100cqw / 100cqhの基準 (= paddingを除いた実際の表示領域)
|
||||
container-type: size;
|
||||
transition: scale 200ms ease, opacity 200ms ease !important;
|
||||
}
|
||||
|
||||
@@ -1021,7 +985,6 @@ defineExpose({
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playIcon {
|
||||
@@ -1037,8 +1000,8 @@ defineExpose({
|
||||
transition: scale 100ms ease;
|
||||
}
|
||||
|
||||
// アイコン自体はクリックを受け取らないので、hoverは下のvideo要素を経由して拾う
|
||||
.video:hover ~ .playIconWrapper .playIcon {
|
||||
.playIconWrapper:hover .playIcon,
|
||||
.playIcon:hover {
|
||||
scale: 1.2;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { url as baseUrl } from '@@/js/config.js';
|
||||
import { tryParseUrl } from '@@/js/url.js';
|
||||
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
||||
import { shuffle } from '@/utility/shuffle.js';
|
||||
|
||||
@@ -57,8 +56,8 @@ const tick = () => {
|
||||
}
|
||||
items.value = feed.items.filter((item) => {
|
||||
if (!item.link) return false;
|
||||
const itemUrl = tryParseUrl(item.link, baseUrl);
|
||||
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
const itemUrl = new URL(item.link, baseUrl);
|
||||
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
});
|
||||
fetching.value = false;
|
||||
key.value++;
|
||||
|
||||
@@ -24,11 +24,10 @@ import { ref, watch, computed } from 'vue';
|
||||
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 { i18n } from '@/i18n.js';
|
||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
|
||||
const name = 'rss';
|
||||
@@ -84,8 +83,8 @@ const tick = () => {
|
||||
.then((feed: Misskey.entities.FetchRssResponse) => {
|
||||
rawItems.value = feed.items.filter((item) => {
|
||||
if (!item.link) return false;
|
||||
const itemUrl = tryParseUrl(item.link, base);
|
||||
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
const itemUrl = new URL(item.link, base);
|
||||
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
});
|
||||
fetching.value = false;
|
||||
});
|
||||
|
||||
@@ -29,16 +29,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
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 type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||
import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import { shuffle } from '@/utility/shuffle.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { url as base } from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
|
||||
const name = 'rssTicker';
|
||||
|
||||
@@ -124,8 +123,8 @@ const tick = () => {
|
||||
.then((feed: Misskey.entities.FetchRssResponse) => {
|
||||
rawItems.value = feed.items.filter((item) => {
|
||||
if (!item.link) return false;
|
||||
const itemUrl = tryParseUrl(item.link, base);
|
||||
return itemUrl != null && ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
const itemUrl = new URL(item.link, base);
|
||||
return ['http:', 'https:'].includes(itemUrl.protocol);
|
||||
});
|
||||
fetching.value = false;
|
||||
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",
|
||||
"name": "misskey-js",
|
||||
"version": "2026.7.0",
|
||||
"version": "2026.7.0-rc.0",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
|
||||
Reference in New Issue
Block a user