feat(frontend/MkUrlPreview): support expanding ActivityPub notes

This commit is contained in:
Kagami Sascha Rosylight 2023-08-06 22:44:00 +02:00
parent ec229dbd3b
commit 47986eb6f5
8 changed files with 136 additions and 60 deletions

1
locales/index.d.ts vendored
View File

@ -589,6 +589,7 @@ export interface Locale {
"enablePlayer": string; "enablePlayer": string;
"disablePlayer": string; "disablePlayer": string;
"expandTweet": string; "expandTweet": string;
"expandNote": string;
"themeEditor": string; "themeEditor": string;
"description": string; "description": string;
"describeFile": string; "describeFile": string;

View File

@ -586,6 +586,7 @@ useCw: "内容を隠す"
enablePlayer: "プレイヤーを開く" enablePlayer: "プレイヤーを開く"
disablePlayer: "プレイヤーを閉じる" disablePlayer: "プレイヤーを閉じる"
expandTweet: "ツイートを展開する" expandTweet: "ツイートを展開する"
expandNote: "ノートを展開する"
themeEditor: "テーマエディター" themeEditor: "テーマエディター"
description: "説明" description: "説明"
describeFile: "キャプションを付ける" describeFile: "キャプションを付ける"

View File

@ -134,7 +134,7 @@
"start-server-and-test": "2.0.0", "start-server-and-test": "2.0.0",
"storybook": "7.0.27", "storybook": "7.0.27",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly#d2d8db49943ccb201c1b1b283e9d0a630519fac7",
"vite-plugin-turbosnap": "1.0.2", "vite-plugin-turbosnap": "1.0.2",
"vitest": "0.33.0", "vitest": "0.33.0",
"vitest-fetch-mock": "0.2.2", "vitest-fetch-mock": "0.2.2",

View File

@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <MkNoteSimple v-if="appearNote.renote" :note="appearNote.renote" :quoted="true"/>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
</button> </button>
@ -758,17 +758,6 @@ function showReactions(): void {
font-size: 80%; font-size: 80%;
} }
.quote {
padding: 8px 0;
}
.quoteNote {
padding: 16px;
border: dashed 1px var(--renote);
border-radius: 8px;
overflow: clip;
}
.channel { .channel {
opacity: 0.7; opacity: 0.7;
font-size: 80%; font-size: 80%;
@ -905,12 +894,6 @@ function showReactions(): void {
} }
} }
@container (max-width: 250px) {
.quoteNote {
padding: 12px;
}
}
.muted { .muted {
padding: 8px; padding: 8px;
text-align: center; text-align: center;

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="$style.root"> <div :class="[$style.root, quoted ? $style.quoted : null]">
<MkAvatar :class="$style.avatar" :user="note.user" link preview/> <MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main"> <div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
@ -32,6 +32,7 @@ import { $i } from '@/account';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
pinned?: boolean; pinned?: boolean;
quoted?: boolean;
}>(); }>();
const showContent = $ref(false); const showContent = $ref(false);
@ -80,12 +81,24 @@ const showContent = $ref(false);
padding: 0; padding: 0;
} }
.quoted {
margin: 8px 0;
padding: 16px;
border: dashed 1px var(--renote);
border-radius: 8px;
overflow: clip;
}
@container (min-width: 250px) { @container (min-width: 250px) {
.avatar { .avatar {
margin: 0 10px 0 0; margin: 0 10px 0 0;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.quoted {
padding: 12px;
}
} }
@container (min-width: 350px) { @container (min-width: 350px) {

View File

@ -26,13 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton> </MkButton>
</div> </div>
</template> </template>
<template v-else-if="tweetId && tweetExpanded"> <template v-else-if="postExpanded">
<div ref="twitter"> <div v-if="tweetId" ref="twitter">
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
</div> </div>
<MkNoteSimple v-else-if="note" :note="note" :quoted="true"/>
<div :class="$style.action"> <div :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = false"> <MkButton :small="true" inline @click="postExpanded = false">
<i class="ti ti-x"></i> {{ i18n.ts.close }} <i v-if="tweetId" class="ti ti-x"></i> {{ i18n.ts.close }}
</MkButton> </MkButton>
</div> </div>
</template> </template>
@ -59,10 +60,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</component> </component>
<template v-if="showActions"> <template v-if="showActions">
<div v-if="tweetId" :class="$style.action"> <div v-if="tweetId" :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = true"> <MkButton :small="true" inline @click="postExpanded = true">
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }} <i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
</MkButton> </MkButton>
</div> </div>
<div v-if="noteUrl || note" :class="$style.action">
<MkButton :small="true" inline @click="resolveNote()">
{{ i18n.ts.expandNote }}
</MkButton>
</div>
<div v-if="!playerEnabled && player.url" :class="$style.action"> <div v-if="!playerEnabled && player.url" :class="$style.action">
<MkButton :small="true" inline @click="playerEnabled = true"> <MkButton :small="true" inline @click="playerEnabled = true">
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }} <i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
@ -78,11 +84,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onUnmounted } from 'vue'; import { defineAsyncComponent, onUnmounted } from 'vue';
import type { summaly } from 'summaly'; import type { summaly } from 'summaly';
import type * as misskey from 'misskey-js';
import { url as local } from '@/config'; import { url as local } from '@/config';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import * as os from '@/os'; import * as os from '@/os';
import { deviceKind } from '@/scripts/device-kind'; import { deviceKind } from '@/scripts/device-kind';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import { versatileLang } from '@/scripts/intl-const'; import { versatileLang } from '@/scripts/intl-const';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
@ -118,7 +126,9 @@ let player = $ref({
} as SummalyResult['player']); } as SummalyResult['player']);
let playerEnabled = $ref(false); let playerEnabled = $ref(false);
let tweetId = $ref<string | null>(null); let tweetId = $ref<string | null>(null);
let tweetExpanded = $ref(props.detail); let noteUrl = $ref<string | null>(null);
let note = $ref<misskey.entities.Note | null>(null);
let postExpanded = $ref(props.detail);
const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
let tweetHeight = $ref(150); let tweetHeight = $ref(150);
let unknownUrl = $ref(false); let unknownUrl = $ref(false);
@ -137,22 +147,15 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
requestUrl.hash = ''; requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) (async (): Promise<void> => {
.then(res => { const res = await window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`);
if (!res.ok) { if (!res.ok) {
fetching = false; fetching = false;
unknownUrl = true; unknownUrl = true;
return; return;
} }
return res.json(); const info = await res.json() as SummalyResult;
})
.then((info: SummalyResult) => {
if (info.url == null) {
fetching = false;
unknownUrl = true;
return;
}
fetching = false; fetching = false;
unknownUrl = false; unknownUrl = false;
@ -163,9 +166,41 @@ window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLa
icon = info.icon; icon = info.icon;
sitename = info.sitename; sitename = info.sitename;
player = info.player; player = info.player;
}); noteUrl = info.activityPub;
function adjustTweetHeight(message: any) { if (postExpanded) {
await resolveNote();
}
})();
async function resolveNote(): Promise<void> {
if (note) {
// Reuse the data
postExpanded = true;
return;
}
if (!noteUrl) {
// Note does not exist
return;
}
try {
fetching = true;
const result = await os.api('ap/show', { uri: noteUrl });
if (result.type === 'Note') {
note = result.object;
postExpanded = true;
} else {
postExpanded = false;
}
} finally {
// Prevent repeated resolving
noteUrl = null;
fetching = false;
}
}
function adjustTweetHeight(message: any): void {
if (message.origin !== 'https://platform.twitter.com') return; if (message.origin !== 'https://platform.twitter.com') return;
const embed = message.data?.['twttr.embed']; const embed = message.data?.['twttr.embed'];
if (embed?.method !== 'twttr.private.resize') return; if (embed?.method !== 'twttr.private.resize') return;

View File

@ -3,17 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { describe, test, assert, afterEach } from 'vitest'; import { describe, test, assert, afterEach, beforeAll, vi } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue'; import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init'; import './init';
import type { summaly } from 'summaly'; import type { summaly } from 'summaly';
import { components } from '@/components'; import { components } from '@/components';
import { directives } from '@/directives'; import { directives } from '@/directives';
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue';
import type * as misskey from "misskey-js";
type SummalyResult = Awaited<ReturnType<typeof summaly>>; type SummalyResult = Awaited<ReturnType<typeof summaly>>;
describe('MkMediaImage', () => { describe('MkUrlPreview', () => {
const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => { const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
if (!summary.player) { if (!summary.player) {
summary.player = { summary.player = {
@ -47,13 +48,18 @@ describe('MkMediaImage', () => {
return result; return result;
}; };
const renderAndOpenPreview = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => { const renderAndOpenPreview = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
const mkUrlPreview = await renderPreviewBy(summary); const mkUrlPreview = await renderPreviewBy(summary);
const buttons = mkUrlPreview.getAllByRole('button'); const buttons = mkUrlPreview.getAllByRole('button');
buttons[0].click(); buttons[0].click();
// Wait for the click event to be fired // Wait for the click event to be fired
await Promise.resolve(); await Promise.resolve();
return mkUrlPreview;
};
const renderAndOpenPreviewInIFrame = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => {
const mkUrlPreview = await renderAndOpenPreview(summary);
return mkUrlPreview.container.querySelector('iframe'); return mkUrlPreview.container.querySelector('iframe');
}; };
@ -85,7 +91,7 @@ describe('MkMediaImage', () => {
}); });
test('Having a player should setup the iframe', async () => { test('Having a player should setup the iframe', async () => {
const iframe = await renderAndOpenPreview({ const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local', url: 'https://example.local',
player: { player: {
url: 'https://example.local/player', url: 'https://example.local/player',
@ -103,7 +109,7 @@ describe('MkMediaImage', () => {
}); });
test('Having a player with `allow` field should set permissions', async () => { test('Having a player with `allow` field should set permissions', async () => {
const iframe = await renderAndOpenPreview({ const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local', url: 'https://example.local',
player: { player: {
url: 'https://example.local/player', url: 'https://example.local/player',
@ -117,7 +123,7 @@ describe('MkMediaImage', () => {
}); });
test('Having a player width should keep the fixed aspect ratio', async () => { test('Having a player width should keep the fixed aspect ratio', async () => {
const iframe = await renderAndOpenPreview({ const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local', url: 'https://example.local',
player: { player: {
url: 'https://example.local/player', url: 'https://example.local/player',
@ -131,7 +137,7 @@ describe('MkMediaImage', () => {
}); });
test('Having a player width should keep the fixed height', async () => { test('Having a player width should keep the fixed height', async () => {
const iframe = await renderAndOpenPreview({ const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local', url: 'https://example.local',
player: { player: {
url: 'https://example.local/player', url: 'https://example.local/player',
@ -143,4 +149,41 @@ describe('MkMediaImage', () => {
assert.exists(iframe, 'iframe should exist'); assert.exists(iframe, 'iframe should exist');
assert.strictEqual(iframe?.parentElement?.style.paddingTop, '200px'); assert.strictEqual(iframe?.parentElement?.style.paddingTop, '200px');
}); });
describe('ActivityPub notes', () => {
afterEach(() => {
vi.clearAllMocks();
});
test('Preview a note', async () => {
vi.mock('@/os', () => {
return {
api(endpoint: string): unknown {
if (endpoint === 'ap/show') {
return {
type: 'Note',
object: {
text: 'Mizuki',
createdAt: new Date().toISOString(),
user: {},
files: [] as misskey.entities.DriveFile[],
} as misskey.entities.Note,
};
}
throw new Error(`Unexpected api call ${endpoint}`);
},
};
});
const url = 'https://example.local';
const renderResult = await renderAndOpenPreview({
url,
description: 'Misskey',
activityPub: url,
});
assert.notExists(renderResult.queryByText('Misskey'), 'Original description should disappear');
assert.exists(renderResult.queryByText('Mizuki'), 'ActivityPub fetch result should appear');
});
});
}); });

View File

@ -996,7 +996,7 @@ importers:
specifier: github:misskey-dev/storybook-addon-misskey-theme specifier: github:misskey-dev/storybook-addon-misskey-theme
version: github.com/misskey-dev/storybook-addon-misskey-theme/cf583db098365b2ccc81a82f63ca9c93bc32b640(@storybook/blocks@7.0.27)(@storybook/components@7.1.0)(@storybook/core-events@7.0.27)(@storybook/manager-api@7.0.27)(@storybook/preview-api@7.0.27)(@storybook/theming@7.0.27)(@storybook/types@7.0.27)(react-dom@18.2.0)(react@18.2.0) version: github.com/misskey-dev/storybook-addon-misskey-theme/cf583db098365b2ccc81a82f63ca9c93bc32b640(@storybook/blocks@7.0.27)(@storybook/components@7.1.0)(@storybook/core-events@7.0.27)(@storybook/manager-api@7.0.27)(@storybook/preview-api@7.0.27)(@storybook/theming@7.0.27)(@storybook/types@7.0.27)(react-dom@18.2.0)(react@18.2.0)
summaly: summaly:
specifier: github:misskey-dev/summaly specifier: github:misskey-dev/summaly#d2d8db49943ccb201c1b1b283e9d0a630519fac7
version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7 version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7
vite-plugin-turbosnap: vite-plugin-turbosnap:
specifier: 1.0.2 specifier: 1.0.2