From e1a541d60bee47b3893db1b191386d263ebef464 Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 2 Jun 2024 00:03:46 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=8E=E3=83=BC=E3=83=88=E3=83=BB?=
 =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6TL=E5=9F=8B=E3=82=81=E8=BE=BC?=
 =?UTF-8?q?=E3=81=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/server/web/ClientServerService.ts     |   4 +-
 packages/backend/src/server/web/style.css     |  13 ++
 .../backend/src/server/web/views/base.pug     |   2 +-
 packages/frontend/src/_boot_.ts               |  30 +++--
 packages/frontend/src/boot/common.ts          |   2 +-
 packages/frontend/src/boot/sub-boot.ts        |   4 +-
 packages/frontend/src/pages/embed/index.vue   |   9 --
 packages/frontend/src/pages/embed/note.vue    |  33 +++++
 .../src/pages/embed/user-timeline.vue         |  57 +++++++++
 packages/frontend/src/router/definition.ts    |  11 +-
 packages/frontend/src/scripts/post-message.ts |  18 ++-
 packages/frontend/src/style.scss              |   7 ++
 packages/frontend/src/ui/embed.vue            | 113 ++++++++++++++++++
 packages/frontend/src/ui/minimum.vue          |  58 +--------
 14 files changed, 277 insertions(+), 84 deletions(-)
 delete mode 100644 packages/frontend/src/pages/embed/index.vue
 create mode 100644 packages/frontend/src/pages/embed/note.vue
 create mode 100644 packages/frontend/src/pages/embed/user-timeline.vue
 create mode 100644 packages/frontend/src/ui/embed.vue

diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 8554e98aec..f42cefe986 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -764,9 +764,9 @@ export class ClientServerService {
 		//#endregion
 
 		//#region embed pages
-		fastify.get('/embed/:path(.*)', async (request, reply) => {
+		fastify.get('/embed/*', async (request, reply) => {
 			reply.removeHeader('X-Frame-Options');
-			return await renderBase(reply, { noindex: true });
+			return await renderBase(reply, { noindex: true, embed: true });
 		});
 
 		fastify.get('/_info_card_', async (request, reply) => {
diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css
index e4723c24fd..70a6da694e 100644
--- a/packages/backend/src/server/web/style.css
+++ b/packages/backend/src/server/web/style.css
@@ -9,6 +9,12 @@ html {
 	color: var(--fg);
 }
 
+html.embed {
+	box-sizing: border-box;
+	background-color: transparent;
+	max-width: 500px;
+}
+
 #splash {
 	position: fixed;
 	z-index: 10000;
@@ -22,6 +28,13 @@ html {
 	transition: opacity 0.5s ease;
 }
 
+html.embed #splash {
+	box-sizing: border-box;
+	min-height: 300px;
+	border-radius: var(--radius, 12px);
+	border: 1px solid var(--divider);
+}
+
 #splashIcon {
 	position: absolute;
 	top: 0;
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index ec1325e4e9..897481d369 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -77,7 +77,7 @@ html
 		script
 			include ../boot.js
 
-	body
+	body(class=embed && 'embed')
 		noscript: p
 			| JavaScriptを有効にしてください
 			br
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts
index d44b0553f3..3d2647289d 100644
--- a/packages/frontend/src/_boot_.ts
+++ b/packages/frontend/src/_boot_.ts
@@ -7,21 +7,33 @@
 import 'vite/modulepreload-polyfill';
 
 import '@/style.scss';
+import type { CommonBootOptions } from '@/boot/common.js';
 import { mainBoot } from '@/boot/main-boot.js';
 import { subBoot } from '@/boot/sub-boot.js';
 import { isEmbedPage } from '@/scripts/embed-page.js';
+import { setIframeId, postMessageToParentWindow } from '@/scripts/post-message.js';
 
-const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete', '/embed'];
+const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete'];
 
-if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
-	if (isEmbedPage()) {
-		const params = new URLSearchParams(location.search);
-		const color = params.get('color');
-		if (color && ['light', 'dark'].includes(color)) {
-			subBoot({ forceColorMode: color as 'light' | 'dark' });
-		}
+if (isEmbedPage()) {
+	const bootOptions: Partial<CommonBootOptions> = {};
+
+	const params = new URLSearchParams(location.search);
+	const color = params.get('color');
+	if (color && ['light', 'dark'].includes(color)) {
+		bootOptions.forceColorMode = color as 'light' | 'dark';
 	}
-	
+
+	window.addEventListener('message', event => {
+		if (event.data?.type === 'misskey:embedParent:registerIframeId' && event.data.payload?.iframeId != null) {
+			setIframeId(event.data.payload.iframeId);
+		}
+	});
+
+	subBoot(bootOptions, true).then(() => {
+		postMessageToParentWindow('misskey:embed:ready');
+	});
+} else if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) {
 	subBoot();
 } else {
 	mainBoot();
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index adf3125143..619d451192 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -25,7 +25,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
 import { setupRouter } from '@/router/definition.js';
 
 export type CommonBootOptions = {
-	forceColorMode?: 'dark' | 'light' | 'auto';
+	forceColorMode: 'dark' | 'light' | 'auto';
 };
 
 const defaultCommonBootOptions: CommonBootOptions = {
diff --git a/packages/frontend/src/boot/sub-boot.ts b/packages/frontend/src/boot/sub-boot.ts
index 656b1bf196..ea6dce2d4d 100644
--- a/packages/frontend/src/boot/sub-boot.ts
+++ b/packages/frontend/src/boot/sub-boot.ts
@@ -7,8 +7,8 @@ import { createApp, defineAsyncComponent } from 'vue';
 import { common } from './common.js';
 import type { CommonBootOptions } from './common.js';
 
-export async function subBoot(options?: CommonBootOptions) {
+export async function subBoot(options?: Partial<CommonBootOptions>, isEmbedPage?: boolean) {
 	const { isClientUpdated } = await common(() => createApp(
-		defineAsyncComponent(() => import('@/ui/minimum.vue')),
+		defineAsyncComponent(() => isEmbedPage ? import('@/ui/embed.vue') : import('@/ui/minimum.vue')),
 	), options);
 }
diff --git a/packages/frontend/src/pages/embed/index.vue b/packages/frontend/src/pages/embed/index.vue
deleted file mode 100644
index 71b4bfaea0..0000000000
--- a/packages/frontend/src/pages/embed/index.vue
+++ /dev/null
@@ -1,9 +0,0 @@
-<template>
-<div></div>
-</template>
-
-<script setup lang="ts">
-</script>
-
-<style lang="scss" module>
-</style>
diff --git a/packages/frontend/src/pages/embed/note.vue b/packages/frontend/src/pages/embed/note.vue
new file mode 100644
index 0000000000..6de8fda977
--- /dev/null
+++ b/packages/frontend/src/pages/embed/note.vue
@@ -0,0 +1,33 @@
+<template>
+	<div :class="$style.noteEmbedRoot">
+		<MkLoading v-if="loading"/>
+		<MkNote v-else-if="note" :note="note"/>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkNote from '@/components/MkNote.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+
+const props = defineProps<{
+	noteId: string;
+}>();
+
+const note = ref<Misskey.entities.Note | null>(null);
+const loading = ref(true);
+
+misskeyApi('notes/show', {
+	noteId: props.noteId,
+}).then(res => {
+	note.value = res;
+	loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.noteEmbedRoot {
+	background-color: var(--panel);
+}
+</style>
diff --git a/packages/frontend/src/pages/embed/user-timeline.vue b/packages/frontend/src/pages/embed/user-timeline.vue
new file mode 100644
index 0000000000..e2d7db447e
--- /dev/null
+++ b/packages/frontend/src/pages/embed/user-timeline.vue
@@ -0,0 +1,57 @@
+<template>
+	<div :class="$style.userTimelineRoot">
+		<MkLoading v-if="loading"/>
+		<template v-else-if="user">
+			<div v-if="normalizedShowHeader" :class="$style.userHeader">
+				<MkAvatar :user="user"/>{{ user.name }} のノート
+			</div>
+			<MkNotes :class="$style.userTimelineNotes" :pagination="pagination" :noGap="true"/>
+		</template>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkNotes from '@/components/MkNotes.vue';
+import type { Paging } from '@/components/MkPagination.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+
+const props = defineProps<{
+	username: string;
+	showHeader?: string;
+}>();
+
+const normalizedShowHeader = computed(() => props.showHeader !== 'false');
+
+const user = ref<Misskey.entities.UserLite | null>(null);
+const pagination = computed(() => ({
+	endpoint: 'users/notes',
+	params: {
+		userId: user.value?.id,
+	},
+} as Paging));
+const loading = ref(true);
+
+misskeyApi('users/show', {
+	username: props.username,
+}).then(res => {
+	user.value = res;
+	loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.userTimelineRoot {
+	background-color: var(--panel);
+	height: 100%;
+	max-height: var(--embedMaxHeight, none);
+	display: flex;
+	flex-direction: column;
+}
+
+.userTimelineNotes {
+	flex: 1;
+	overflow-y: auto;
+}
+</style>
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index 66a38b45ee..09d80fcf40 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -556,9 +556,14 @@ const routes: RouteDef[] = [{
 	component: page(() => import('@/pages/reversi/game.vue')),
 	loginRequired: false,
 }, {
-	path: '/embed',
-	component: page(() => import('@/pages/embed/index.vue')),
-//	children: [],
+	path: '/embed/notes/:noteId',
+	component: page(() => import('@/pages/embed/note.vue')),
+}, {
+	path: '/embed/user-timeline/@:username',
+	component: page(() => import('@/pages/embed/user-timeline.vue')),
+	query: {
+		header: 'showHeader',
+	}
 }, {
 	path: '/timeline',
 	component: page(() => import('@/pages/timeline.vue')),
diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts
index 6084ac3e5f..6f63d3c7da 100644
--- a/packages/frontend/src/scripts/post-message.ts
+++ b/packages/frontend/src/scripts/post-message.ts
@@ -5,6 +5,7 @@
 
 export const postMessageEventTypes = [
 	'misskey:shareForm:shareCompleted',
+	'misskey:embed:ready',
 	'misskey:embed:changeHeight',
 ] as const;
 
@@ -12,16 +13,29 @@ export type PostMessageEventType = typeof postMessageEventTypes[number];
 
 export type MiPostMessageEvent = {
 	type: PostMessageEventType;
+	iframeId?: string;
 	payload?: any;
 };
 
+let defaultIframeId: string | null = null;
+
+export function setIframeId(id: string): void {
+	if (_DEV_) console.log('setIframeId', id);
+	defaultIframeId = id;
+}
+
 /**
  * 親フレームにイベントを送信
  */
-export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
-	if (_DEV_) console.log('postMessageToParentWindow', type, payload);
+export function postMessageToParentWindow(type: PostMessageEventType, payload?: any, iframeId: string | null = null): void {
+	let _iframeId = iframeId;
+	if (_iframeId == null) {
+		_iframeId = defaultIframeId;
+	}
+	if (_DEV_) console.log('postMessageToParentWindow', type, _iframeId, payload);
 	window.parent.postMessage({
 		type,
+		iframeId: _iframeId,
 		payload,
 	}, '*');
 }
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 82671dab83..27aa020e0a 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -93,9 +93,16 @@ html {
 
 	&.embed {
 		background-color: transparent;
+		overflow: hidden;
 	}
 }
 
+html.embed,
+html.embed body,
+html.embed #misskey_app {
+	height: 100%;
+}
+
 html._themeChanging_ {
 	&, * {
 		transition: background 1s ease, border 1s ease !important;
diff --git a/packages/frontend/src/ui/embed.vue b/packages/frontend/src/ui/embed.vue
new file mode 100644
index 0000000000..c053781445
--- /dev/null
+++ b/packages/frontend/src/ui/embed.vue
@@ -0,0 +1,113 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+	ref="rootEl"
+	:class="[
+		$style.rootForEmbedPage,
+		{
+			[$style.rounded]: embedRounded,
+		}
+	]"
+	:style="maxHeight > 0 ? { maxHeight: `${maxHeight}px`, '--embedMaxHeight': `${maxHeight}px` } : {}"
+>
+	<div
+		:class="$style.routerViewContainer"
+	>
+		<RouterView/>
+	</div>
+
+	<XCommon/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, provide, ref, shallowRef, onMounted, onUnmounted } from 'vue';
+import XCommon from './_common_/common.vue';
+import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
+import { instanceName } from '@/config.js';
+import { mainRouter } from '@/router/main.js';
+import { postMessageToParentWindow } from '@/scripts/post-message';
+
+const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
+
+const pageMetadata = ref<null | PageMetadata>(null);
+
+provide('router', mainRouter);
+provideMetadataReceiver((metadataGetter) => {
+	const info = metadataGetter();
+	pageMetadata.value = info;
+	if (pageMetadata.value) {
+		if (isRoot.value && pageMetadata.value.title === instanceName) {
+			document.title = pageMetadata.value.title;
+		} else {
+			document.title = `${pageMetadata.value.title} | ${instanceName}`;
+		}
+	}
+});
+provideReactiveMetadata(pageMetadata);
+
+//#region Embed Style
+const params = new URLSearchParams(location.search);
+const embedRounded = ref(params.get('rounded') !== '0');
+const maxHeight = ref(params.get('maxHeight') ? parseInt(params.get('maxHeight')!) : 0);
+//#endregion
+
+//#region Embed Resizer
+const rootEl = shallowRef<HTMLElement | null>(null);
+
+let resizeMessageThrottleTimer: number | null = null;
+let resizeMessageThrottleFlag = false;
+let previousHeight = 0;
+const resizeObserver = new ResizeObserver(async () => {
+	const height = rootEl.value!.scrollHeight + 2; // border 上下1px
+	if (resizeMessageThrottleFlag && Math.abs(previousHeight - height) < 30) return;
+	if (resizeMessageThrottleTimer) window.clearTimeout(resizeMessageThrottleTimer);
+
+	postMessageToParentWindow('misskey:embed:changeHeight', {
+		height: (maxHeight.value > 0 && height > maxHeight.value) ? maxHeight.value : height,
+	});
+	previousHeight = height;
+
+	resizeMessageThrottleFlag = true;
+
+	resizeMessageThrottleTimer = window.setTimeout(() => {
+		resizeMessageThrottleFlag = false; // 収縮をやりすぎるとチカチカする
+	}, 500);
+});
+onMounted(() => {
+	resizeObserver.observe(rootEl.value!);
+});
+onUnmounted(() => {
+	resizeObserver.disconnect();
+});
+//#endregion
+
+document.documentElement.style.maxWidth = '500px';
+
+// サーバー起動の場合はもとから付与されているためdevのみ
+if (_DEV_) document.documentElement.classList.add('embed');
+</script>
+
+<style lang="scss" module>
+.rootForEmbedPage {
+	box-sizing: border-box;
+	border: 1px solid var(--divider);
+	background-color: var(--bg);
+	overflow: hidden;
+	position: relative;
+	height: auto;
+
+	&.rounded {
+		border-radius: var(--radius);
+	}
+}
+
+.routerViewContainer {
+	container-type: inline-size;
+	max-height: var(--embedMaxHeight, none);
+}
+</style>
diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue
index 1f94b55a8b..db5eb19c20 100644
--- a/packages/frontend/src/ui/minimum.vue
+++ b/packages/frontend/src/ui/minimum.vue
@@ -4,15 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div
-	ref="rootEl"
-	:class="isEmbed ? [
-		$style.rootForEmbedPage,
-		{
-			[$style.rounded]: embedRounded,
-		}
-	] : [$style.root]"
->
+<div :class="$style.root">
 	<div style="container-type: inline-size;">
 		<RouterView/>
 	</div>
@@ -22,15 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { computed, provide, ref, shallowRef, onMounted, onUnmounted } from 'vue';
+import { computed, provide, ref } from 'vue';
 import XCommon from './_common_/common.vue';
 import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
 import { instanceName } from '@/config.js';
 import { mainRouter } from '@/router/main.js';
-import { isEmbedPage } from '@/scripts/embed-page.js';
-import { postMessageToParentWindow } from '@/scripts/post-message';
-
-const isEmbed = isEmbedPage();
 
 const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
 
@@ -50,35 +38,7 @@ provideMetadataReceiver((metadataGetter) => {
 });
 provideReactiveMetadata(pageMetadata);
 
-//#region Embed Style
-const params = new URLSearchParams(location.search);
-const embedRounded = ref(params.get('rounded') !== '0');
-//#endregion
-
-//#region Embed Resizer
-const rootEl = shallowRef<HTMLElement | null>(null);
-
-if (isEmbed) {
-	const resizeObserver = new ResizeObserver(async () => {
-		postMessageToParentWindow('misskey:embed:changeHeight', {
-			height: rootEl.value!.scrollHeight + 2, // border 上下1px
-		});
-	});
-	onMounted(() => {
-		resizeObserver.observe(rootEl.value!);
-	});
-	onUnmounted(() => {
-		resizeObserver.disconnect();
-	});
-}
-//#endregion
-
-if (isEmbed) {
-	document.documentElement.style.maxWidth = '500px';
-	document.documentElement.classList.add('embed');
-} else {
-	document.documentElement.style.overflowY = 'scroll';
-}
+document.documentElement.style.overflowY = 'scroll';
 </script>
 
 <style lang="scss" module>
@@ -86,16 +46,4 @@ if (isEmbed) {
 	min-height: 100dvh;
 	box-sizing: border-box;
 }
-
-.rootForEmbedPage {
-	box-sizing: border-box;
-	border: 1px solid var(--divider);
-	background-color: var(--bg);
-	overflow: hidden;
-	position: relative;
-
-	&.rounded {
-		border-radius: var(--radius);
-	}
-}
 </style>