Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontend/MkUrlPreview): support expanding ActivityPub notes #11478

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

### Client
- Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように
- Enhance: ActivityPubをサポートしているウェブリンクを展開できるように

## 2023.12.2

Expand Down
1 change: 1 addition & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ export interface Locale {
"enablePlayer": string;
"disablePlayer": string;
"expandTweet": string;
"expandNote": string;
"themeEditor": string;
"description": string;
"describeFile": string;
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ useCw: "内容を隠す"
enablePlayer: "プレイヤーを開く"
disablePlayer: "プレイヤーを閉じる"
expandTweet: "ポストを展開する"
expandNote: "ノートを展開する"
themeEditor: "テーマエディター"
description: "説明"
describeFile: "キャプションを付ける"
Expand Down
17 changes: 2 additions & 15 deletions packages/frontend/src/components/MkNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<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"/>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<MkNoteSimple v-if="appearNote.renote" :class="$style.quote" :note="appearNote.renote" :quoted="true"/>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
</button>
Expand Down Expand Up @@ -801,14 +801,7 @@ function emitUpdReaction(emoji: string, delta: number) {
}

.quote {
padding: 8px 0;
}

.quoteNote {
padding: 16px;
border: dashed 1px var(--renote);
border-radius: 8px;
overflow: clip;
margin: 8px 0;
}

.channel {
Expand Down Expand Up @@ -947,12 +940,6 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}

@container (max-width: 250px) {
.quoteNote {
padding: 12px;
}
}

.muted {
padding: 8px;
text-align: center;
Expand Down
15 changes: 14 additions & 1 deletion packages/frontend/src/components/MkNoteSimple.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->

<template>
<div :class="$style.root">
<div :class="[$style.root, quoted ? $style.quoted : null]">
<MkAvatar :class="$style.avatar" :user="note.user" link preview/>
<div :class="$style.main">
<MkNoteHeader :class="$style.header" :note="note" :mini="true"/>
Expand All @@ -30,6 +30,8 @@ import MkCwButton from '@/components/MkCwButton.vue';

const props = defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
quoted?: boolean;
}>();

const showContent = ref(false);
Expand Down Expand Up @@ -78,12 +80,23 @@ const showContent = ref(false);
padding: 0;
}

.quoted {
padding: 16px;
border: dashed 1px var(--renote);
border-radius: 8px;
overflow: clip;
}

@container (min-width: 250px) {
.avatar {
margin: 0 10px 0 0;
width: 40px;
height: 40px;
}

.quoted {
padding: 12px;
}
}

@container (min-width: 350px) {
Expand Down
61 changes: 51 additions & 10 deletions packages/frontend/src/components/MkUrlPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->

<template>
<template v-if="player.url && playerEnabled">
<div 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`"
Expand All @@ -25,9 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-x"></i> {{ i18n.ts.disablePlayer }}
</MkButton>
</div>
</template>
<template v-else-if="tweetId && tweetExpanded">
<div ref="twitter">
</div>
<div v-else-if="postExpanded">
<div v-if="tweetId" ref="twitter">
<iframe
ref="tweet"
allow="fullscreen;web-share"
Expand All @@ -37,12 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only
: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>
<MkNoteSimple v-else-if="note" :note="note" :quoted="true"/>
<div :class="$style.action">
<MkButton :small="true" inline @click="tweetExpanded = false">
<i class="ti ti-x"></i> {{ i18n.ts.close }}
<MkButton :small="true" inline @click="postExpanded = false">
<i v-if="tweetId" class="ti ti-x"></i> {{ i18n.ts.close }}
</MkButton>
</div>
</template>
</div>
<div v-else>
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`">
Expand All @@ -66,10 +67,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</component>
<template v-if="showActions">
<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 }}
</MkButton>
</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">
<MkButton :small="true" inline @click="playerEnabled = true">
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
Expand All @@ -85,11 +91,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, onUnmounted, ref } from 'vue';
import type { summaly } from 'summaly';
import type * as Misskey from 'misskey-js';
import { url as local } from '@/config.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkButton from '@/components/MkButton.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import { versatileLang } from '@/scripts/intl-const.js';
import { defaultStore } from '@/store.js';

Expand Down Expand Up @@ -126,7 +134,9 @@ const player = ref({
} as SummalyResult['player']);
const playerEnabled = ref(false);
const tweetId = ref<string | null>(null);
const tweetExpanded = ref(props.detail);
const noteUrl = ref<string | null>(null);
const note = ref<Misskey.entities.Note | null>(null);
const postExpanded = ref(props.detail);
const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
const tweetHeight = ref(150);
const unknownUrl = ref(false);
Expand Down Expand Up @@ -172,9 +182,40 @@ window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLa
sitename.value = info.sitename;
player.value = info.player;
sensitive.value = info.sensitive ?? false;
noteUrl.value = info.activityPub;
if (postExpanded.value) {
resolveNote();
}
});

function adjustTweetHeight(message: any) {
async function resolveNote(): Promise<void> {
if (note.value) {
// Reuse the data
postExpanded.value = true;
return;
}
if (!noteUrl.value) {
// Note does not exist
return;
}

try {
fetching.value = true;
const result = await os.api('ap/show', { uri: noteUrl.value });
if (result.type === 'Note') {
note.value = result.object;
postExpanded.value = true;
} else {
postExpanded.value = false;
}
} finally {
// Prevent repeated resolving
noteUrl.value = null;
fetching.value = false;
}
}

function adjustTweetHeight(message: any): void {
if (message.origin !== 'https://platform.twitter.com') return;
const embed = message.data?.['twttr.embed'];
if (embed?.method !== 'twttr.private.resize') return;
Expand Down
59 changes: 51 additions & 8 deletions packages/frontend/test/url-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
* 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 './init';
import type { summaly } from 'summaly';
import type * as misskey from 'misskey-js';
import { components } from '@/components/index.js';
import { directives } from '@/directives/index.js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
Expand Down Expand Up @@ -47,13 +48,18 @@ describe('MkUrlPreview', () => {
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 buttons = mkUrlPreview.getAllByRole('button');
buttons[0].click();
// Wait for the click event to be fired
await Promise.resolve();

return mkUrlPreview;
};

const renderAndOpenPreviewInIFrame = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => {
const mkUrlPreview = await renderAndOpenPreview(summary);
return mkUrlPreview.container.querySelector('iframe');
};

Expand Down Expand Up @@ -85,7 +91,7 @@ describe('MkUrlPreview', () => {
});

test('Having a player should setup the iframe', async () => {
const iframe = await renderAndOpenPreview({
const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local',
player: {
url: 'https://example.local/player',
Expand All @@ -103,7 +109,7 @@ describe('MkUrlPreview', () => {
});

test('Having a player with `allow` field should set permissions', async () => {
const iframe = await renderAndOpenPreview({
const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local',
player: {
url: 'https://example.local/player',
Expand All @@ -117,7 +123,7 @@ describe('MkUrlPreview', () => {
});

test('Having a player width should keep the fixed aspect ratio', async () => {
const iframe = await renderAndOpenPreview({
const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local',
player: {
url: 'https://example.local/player',
Expand All @@ -131,7 +137,7 @@ describe('MkUrlPreview', () => {
});

test('Having a player width should keep the fixed height', async () => {
const iframe = await renderAndOpenPreview({
const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://example.local',
player: {
url: 'https://example.local/player',
Expand All @@ -145,7 +151,7 @@ describe('MkUrlPreview', () => {
});

test('Loading a tweet in iframe', async () => {
const iframe = await renderAndOpenPreview({
const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://twitter.com/i/web/status/1685072521782325249',
});
assert.exists(iframe, 'iframe should exist');
Expand All @@ -154,11 +160,48 @@ describe('MkUrlPreview', () => {
});

test('Loading a post in iframe', async () => {
const iframe = await renderAndOpenPreview({
const iframe = await renderAndOpenPreviewInIFrame({
url: 'https://x.com/i/web/status/1685072521782325249',
});
assert.exists(iframe, 'iframe should exist');
assert.strictEqual(iframe?.getAttribute('allow'), 'fullscreen;web-share');
assert.strictEqual(iframe?.getAttribute('sandbox'), 'allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin');
});

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');
});
});
});