feat(frontend/MkUrlPreview): oEmbedのサポート (#10306)
* feat(frontend/MkUrlPreview): oEmbedのサポート * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * playerとoEmbedの統合 * Update CHANGELOG.md * loading=lazyはここでは不要 * border: 0 * プレビュー直後にautoplayできる機能の復旧 * add test * refactor test * explain about cache * expandPreviewはもう使わない * summaly v4 * update summaly * scrolling=no to fix pixiv --------- Co-authored-by: tamaina <tamaina@hotmail.co.jp>
This commit is contained in:

committed by
GitHub

parent
4d73080da1
commit
c091d9e6d5
@@ -97,7 +97,9 @@
|
||||
"eslint-plugin-vue": "9.9.0",
|
||||
"happy-dom": "8.9.0",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vitest": "^0.29.2",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vue-eslint-parser": "9.1.0",
|
||||
"vue-tsc": "1.2.0"
|
||||
}
|
||||
|
@@ -1,7 +1,18 @@
|
||||
<template>
|
||||
<template v-if="playerEnabled">
|
||||
<div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
<template v-if="player.url && playerEnabled">
|
||||
<div
|
||||
:class="$style.player"
|
||||
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
|
||||
>
|
||||
<iframe
|
||||
v-if="player.url.startsWith('http://') || player.url.startsWith('https://')"
|
||||
sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin"
|
||||
scrolling="no"
|
||||
:allow="player.allow.join(';')"
|
||||
:class="$style.playerIframe"
|
||||
:src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')"
|
||||
:style="{ border: 0 }"
|
||||
></iframe>
|
||||
<span v-else>invalid url</span>
|
||||
</div>
|
||||
<div :class="$style.action">
|
||||
@@ -28,7 +39,7 @@
|
||||
<header :class="$style.header">
|
||||
<h1 v-if="unknownUrl" :class="$style.title">{{ url }}</h1>
|
||||
<h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1>
|
||||
<h1 v-else :class="$style.title" :title="title">{{ title }}</h1>
|
||||
<h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1>
|
||||
</header>
|
||||
<p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.cannotLoad }}</p>
|
||||
<p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p>
|
||||
@@ -37,7 +48,7 @@
|
||||
<img v-if="icon" :class="$style.siteIcon" :src="icon"/>
|
||||
<p v-if="unknownUrl" :class="$style.siteName">?</p>
|
||||
<p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p>
|
||||
<p v-else :class="$style.siteName" :title="sitename">{{ sitename }}</p>
|
||||
<p v-else :class="$style.siteName" :title="sitename ?? undefined">{{ sitename }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</component>
|
||||
@@ -59,6 +70,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onUnmounted } from 'vue';
|
||||
import type { summaly } from 'summaly';
|
||||
import { url as local } from '@/config';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
@@ -66,6 +78,8 @@ import { deviceKind } from '@/scripts/device-kind';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { versatileLang } from '@/scripts/intl-const';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
detail?: boolean;
|
||||
@@ -91,7 +105,7 @@ let player = $ref({
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
} as SummalyResult['player']);
|
||||
let playerEnabled = $ref(false);
|
||||
let tweetId = $ref<string | null>(null);
|
||||
let tweetExpanded = $ref(props.detail);
|
||||
@@ -114,11 +128,7 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
|
||||
requestUrl.hash = '';
|
||||
|
||||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => {
|
||||
res.json().then(info => {
|
||||
if (info.url == null) {
|
||||
unknownUrl = true;
|
||||
return;
|
||||
}
|
||||
res.json().then((info: SummalyResult) => {
|
||||
title = info.title;
|
||||
description = info.description;
|
||||
thumbnail = info.thumbnail;
|
||||
|
@@ -1,4 +1,8 @@
|
||||
import { vi } from 'vitest';
|
||||
import createFetchMock from 'vitest-fetch-mock';
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
fetchMocker.enableMocks();
|
||||
|
||||
// Set i18n
|
||||
import locales from '../../../locales';
|
||||
|
140
packages/frontend/test/url-preview.test.ts
Normal file
140
packages/frontend/test/url-preview.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, test, assert, afterEach } from 'vitest';
|
||||
import { render, cleanup, type RenderResult } from '@testing-library/vue';
|
||||
import './init';
|
||||
import type { summaly } from 'summaly';
|
||||
import { directives } from '@/directives';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
describe('MkMediaImage', () => {
|
||||
const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
|
||||
if (!summary.player) {
|
||||
summary.player = {
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
};
|
||||
}
|
||||
|
||||
fetchMock.mockOnceIf(/^\/url?/, () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify(summary),
|
||||
};
|
||||
});
|
||||
|
||||
const result = render(MkUrlPreview, {
|
||||
props: { url: summary.url },
|
||||
global: { directives },
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const observer = new MutationObserver(() => {
|
||||
resolve();
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(result.container, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const renderAndOpenPreview = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => {
|
||||
const mkUrlPreview = await renderPreviewBy(summary);
|
||||
const buttons = mkUrlPreview.getAllByRole('button');
|
||||
buttons[0].click();
|
||||
// Wait for the click event to be fired
|
||||
await Promise.resolve();
|
||||
|
||||
return mkUrlPreview.container.querySelector('iframe');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('Should render the description', async () => {
|
||||
const mkUrlPreview = await renderPreviewBy({
|
||||
url: 'https://example.local',
|
||||
description: 'Mocked description',
|
||||
});
|
||||
mkUrlPreview.getByText('Mocked description');
|
||||
});
|
||||
|
||||
test('Having a player should render a button', async () => {
|
||||
const mkUrlPreview = await renderPreviewBy({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
const buttons = mkUrlPreview.getAllByRole('button');
|
||||
assert.strictEqual(buttons.length, 2, 'two buttons');
|
||||
});
|
||||
|
||||
test('Having a player should setup the iframe', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.src, 'https://example.local/player?autoplay=1&auto_play=1');
|
||||
assert.strictEqual(
|
||||
iframe?.sandbox.toString(),
|
||||
'allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin',
|
||||
);
|
||||
});
|
||||
|
||||
test('Having a player with `allow` field should set permissions', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: ['fullscreen', 'web-share'],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.allow, 'fullscreen;web-share');
|
||||
});
|
||||
|
||||
test('Having a player width should keep the fixed aspect ratio', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: 400,
|
||||
height: 200,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.parentElement?.style.paddingTop, '50%');
|
||||
});
|
||||
|
||||
test('Having a player width should keep the fixed height', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: 200,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.parentElement?.style.paddingTop, '200px');
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user