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