Compare commits

...

4 commits

Author SHA1 Message Date
Kagami Sascha Rosylight
d8434a206f Update CHANGELOG.md 2023-12-30 17:22:30 +01:00
Kagami Sascha Rosylight
83159600ea use div for all url preview form 2023-12-30 16:45:35 +01:00
Kagami Sascha Rosylight
12b82aca5f remove margin from MkNoteSimple root 2023-12-30 16:31:34 +01:00
Kagami Sascha Rosylight
73842166ee feat(frontend/MkUrlPreview): support expanding ActivityPub notes 2023-12-30 15:32:59 +01:00
7 changed files with 121 additions and 34 deletions

View file

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

1
locales/index.d.ts vendored
View file

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

View file

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

View file

@ -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>
@ -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 {
@ -947,12 +940,6 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
@container (max-width: 250px) {
.quoteNote {
padding: 12px;
}
}
.muted {
padding: 8px;
text-align: center;

View file

@ -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"/>
@ -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);
@ -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) {

View file

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

View file

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