From 47986eb6f508c3ee8a8666b30306794c03cb2431 Mon Sep 17 00:00:00 2001
From: Kagami Sascha Rosylight <saschanaz@outlook.com>
Date: Sun, 6 Aug 2023 22:44:00 +0200
Subject: [PATCH] feat(frontend/MkUrlPreview): support expanding ActivityPub
 notes

---
 locales/index.d.ts                            |  1 +
 locales/ja-JP.yml                             |  1 +
 packages/frontend/package.json                |  2 +-
 packages/frontend/src/components/MkNote.vue   | 19 +---
 .../frontend/src/components/MkNoteSimple.vue  | 15 ++-
 .../frontend/src/components/MkUrlPreview.vue  | 97 +++++++++++++------
 packages/frontend/test/url-preview.test.ts    | 57 +++++++++--
 pnpm-lock.yaml                                |  2 +-
 8 files changed, 135 insertions(+), 59 deletions(-)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 4dceec6050..3ec926b92f 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -589,6 +589,7 @@ export interface Locale {
     "enablePlayer": string;
     "disablePlayer": string;
     "expandTweet": string;
+    "expandNote": string;
     "themeEditor": string;
     "description": string;
     "describeFile": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index cab5c8f97a..d31db56fd4 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -586,6 +586,7 @@ useCw: "内容を隠す"
 enablePlayer: "プレイヤーを開く"
 disablePlayer: "プレイヤーを閉じる"
 expandTweet: "ツイートを展開する"
+expandNote: "ノートを展開する"
 themeEditor: "テーマエディター"
 description: "説明"
 describeFile: "キャプションを付ける"
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 7d3c8b9767..79a6a5b203 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -134,7 +134,7 @@
 		"start-server-and-test": "2.0.0",
 		"storybook": "7.0.27",
 		"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",
 		"vitest": "0.33.0",
 		"vitest-fetch-mock": "0.2.2",
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 6cd1a4c4b5..a6e1058992 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -74,7 +74,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" :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>
@@ -758,17 +758,6 @@ function showReactions(): void {
 	font-size: 80%;
 }
 
-.quote {
-	padding: 8px 0;
-}
-
-.quoteNote {
-	padding: 16px;
-	border: dashed 1px var(--renote);
-	border-radius: 8px;
-	overflow: clip;
-}
-
 .channel {
 	opacity: 0.7;
 	font-size: 80%;
@@ -905,12 +894,6 @@ function showReactions(): void {
 	}
 }
 
-@container (max-width: 250px) {
-	.quoteNote {
-		padding: 12px;
-	}
-}
-
 .muted {
 	padding: 8px;
 	text-align: center;
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 9648b7230a..c6c843acd2 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -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"/>
@@ -32,6 +32,7 @@ import { $i } from '@/account';
 const props = defineProps<{
 	note: misskey.entities.Note;
 	pinned?: boolean;
+	quoted?: boolean;
 }>();
 
 const showContent = $ref(false);
@@ -80,12 +81,24 @@ const showContent = $ref(false);
 	padding: 0;
 }
 
+.quoted {
+	margin: 8px 0;
+	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) {
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 1a194ae9db..798feddc56 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -26,13 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</MkButton>
 	</div>
 </template>
-<template v-else-if="tweetId && tweetExpanded">
-	<div ref="twitter">
+<template v-else-if="postExpanded">
+	<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>
 	</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>
@@ -59,10 +60,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 }}
@@ -78,11 +84,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent, onUnmounted } from 'vue';
 import type { summaly } from 'summaly';
+import type * as misskey from 'misskey-js';
 import { url as local } from '@/config';
 import { i18n } from '@/i18n';
 import * as os from '@/os';
 import { deviceKind } from '@/scripts/device-kind';
 import MkButton from '@/components/MkButton.vue';
+import MkNoteSimple from '@/components/MkNoteSimple.vue';
 import { versatileLang } from '@/scripts/intl-const';
 import { defaultStore } from '@/store';
 
@@ -118,7 +126,9 @@ let player = $ref({
 } as SummalyResult['player']);
 let playerEnabled = $ref(false);
 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/, '')}`;
 let tweetHeight = $ref(150);
 let unknownUrl = $ref(false);
@@ -137,35 +147,60 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
 
 requestUrl.hash = '';
 
-window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
-	.then(res => {
-		if (!res.ok) {
-			fetching = false;
-			unknownUrl = true;
-			return;
-		}
+(async (): Promise<void> => {
+	const res = await window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`);
+	if (!res.ok) {
+		fetching = false;
+		unknownUrl = true;
+		return;
+	}
 
-		return res.json();
-	})
-	.then((info: SummalyResult) => {
-		if (info.url == null) {
-			fetching = false;
-			unknownUrl = true;
-			return;
-		}
+	const info = await res.json() as SummalyResult;
+
+	fetching = false;
+	unknownUrl = false;
 
+	title = info.title;
+	description = info.description;
+	thumbnail = info.thumbnail;
+	icon = info.icon;
+	sitename = info.sitename;
+	player = info.player;
+	noteUrl = info.activityPub;
+
+	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;
-		unknownUrl = false;
-
-		title = info.title;
-		description = info.description;
-		thumbnail = info.thumbnail;
-		icon = info.icon;
-		sitename = info.sitename;
-		player = info.player;
-	});
+	}
+}
 
-function adjustTweetHeight(message: any) {
+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;
diff --git a/packages/frontend/test/url-preview.test.ts b/packages/frontend/test/url-preview.test.ts
index 1d43a628f2..8d3c322df6 100644
--- a/packages/frontend/test/url-preview.test.ts
+++ b/packages/frontend/test/url-preview.test.ts
@@ -3,17 +3,18 @@
  * 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 { components } from '@/components';
 import { directives } from '@/directives';
 import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import type * as misskey from "misskey-js";
 
 type SummalyResult = Awaited<ReturnType<typeof summaly>>;
 
-describe('MkMediaImage', () => {
+describe('MkUrlPreview', () => {
 	const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
 		if (!summary.player) {
 			summary.player = {
@@ -47,13 +48,18 @@ describe('MkMediaImage', () => {
 		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('MkMediaImage', () => {
 	});
 
 	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('MkMediaImage', () => {
 	});
 
 	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('MkMediaImage', () => {
 	});
 
 	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('MkMediaImage', () => {
 	});
 
 	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',
@@ -143,4 +149,41 @@ describe('MkMediaImage', () => {
 		assert.exists(iframe, 'iframe should exist');
 		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');
+		});
+	});
 });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a559e867e..3c0cd9ac15 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -996,7 +996,7 @@ importers:
         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)
       summaly:
-        specifier: github:misskey-dev/summaly
+        specifier: github:misskey-dev/summaly#d2d8db49943ccb201c1b1b283e9d0a630519fac7
         version: github.com/misskey-dev/summaly/d2d8db49943ccb201c1b1b283e9d0a630519fac7
       vite-plugin-turbosnap:
         specifier: 1.0.2
-- 
GitLab