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>