feat: ノート・ユーザTL埋め込み

This commit is contained in:
kakkokari-gtyih 2024-06-02 00:03:46 +09:00
parent f80c5d26b5
commit e1a541d60b
14 changed files with 277 additions and 84 deletions

View File

@ -764,9 +764,9 @@ export class ClientServerService {
//#endregion //#endregion
//#region embed pages //#region embed pages
fastify.get('/embed/:path(.*)', async (request, reply) => { fastify.get('/embed/*', async (request, reply) => {
reply.removeHeader('X-Frame-Options'); 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) => { fastify.get('/_info_card_', async (request, reply) => {

View File

@ -9,6 +9,12 @@ html {
color: var(--fg); color: var(--fg);
} }
html.embed {
box-sizing: border-box;
background-color: transparent;
max-width: 500px;
}
#splash { #splash {
position: fixed; position: fixed;
z-index: 10000; z-index: 10000;
@ -22,6 +28,13 @@ html {
transition: opacity 0.5s ease; 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 { #splashIcon {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -77,7 +77,7 @@ html
script script
include ../boot.js include ../boot.js
body body(class=embed && 'embed')
noscript: p noscript: p
| JavaScriptを有効にしてください | JavaScriptを有効にしてください
br br

View File

@ -7,21 +7,33 @@
import 'vite/modulepreload-polyfill'; import 'vite/modulepreload-polyfill';
import '@/style.scss'; import '@/style.scss';
import type { CommonBootOptions } from '@/boot/common.js';
import { mainBoot } from '@/boot/main-boot.js'; import { mainBoot } from '@/boot/main-boot.js';
import { subBoot } from '@/boot/sub-boot.js'; import { subBoot } from '@/boot/sub-boot.js';
import { isEmbedPage } from '@/scripts/embed-page.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()) {
if (isEmbedPage()) { const bootOptions: Partial<CommonBootOptions> = {};
const params = new URLSearchParams(location.search);
const color = params.get('color'); const params = new URLSearchParams(location.search);
if (color && ['light', 'dark'].includes(color)) { const color = params.get('color');
subBoot({ forceColorMode: color as 'light' | 'dark' }); 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(); subBoot();
} else { } else {
mainBoot(); mainBoot();

View File

@ -25,7 +25,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } from '@/router/definition.js'; import { setupRouter } from '@/router/definition.js';
export type CommonBootOptions = { export type CommonBootOptions = {
forceColorMode?: 'dark' | 'light' | 'auto'; forceColorMode: 'dark' | 'light' | 'auto';
}; };
const defaultCommonBootOptions: CommonBootOptions = { const defaultCommonBootOptions: CommonBootOptions = {

View File

@ -7,8 +7,8 @@ import { createApp, defineAsyncComponent } from 'vue';
import { common } from './common.js'; import { common } from './common.js';
import type { CommonBootOptions } 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( const { isClientUpdated } = await common(() => createApp(
defineAsyncComponent(() => import('@/ui/minimum.vue')), defineAsyncComponent(() => isEmbedPage ? import('@/ui/embed.vue') : import('@/ui/minimum.vue')),
), options); ), options);
} }

View File

@ -1,9 +0,0 @@
<template>
<div></div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" module>
</style>

View File

@ -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>

View File

@ -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>

View File

@ -556,9 +556,14 @@ const routes: RouteDef[] = [{
component: page(() => import('@/pages/reversi/game.vue')), component: page(() => import('@/pages/reversi/game.vue')),
loginRequired: false, loginRequired: false,
}, { }, {
path: '/embed', path: '/embed/notes/:noteId',
component: page(() => import('@/pages/embed/index.vue')), component: page(() => import('@/pages/embed/note.vue')),
// children: [], }, {
path: '/embed/user-timeline/@:username',
component: page(() => import('@/pages/embed/user-timeline.vue')),
query: {
header: 'showHeader',
}
}, { }, {
path: '/timeline', path: '/timeline',
component: page(() => import('@/pages/timeline.vue')), component: page(() => import('@/pages/timeline.vue')),

View File

@ -5,6 +5,7 @@
export const postMessageEventTypes = [ export const postMessageEventTypes = [
'misskey:shareForm:shareCompleted', 'misskey:shareForm:shareCompleted',
'misskey:embed:ready',
'misskey:embed:changeHeight', 'misskey:embed:changeHeight',
] as const; ] as const;
@ -12,16 +13,29 @@ export type PostMessageEventType = typeof postMessageEventTypes[number];
export type MiPostMessageEvent = { export type MiPostMessageEvent = {
type: PostMessageEventType; type: PostMessageEventType;
iframeId?: string;
payload?: any; 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 { export function postMessageToParentWindow(type: PostMessageEventType, payload?: any, iframeId: string | null = null): void {
if (_DEV_) console.log('postMessageToParentWindow', type, payload); let _iframeId = iframeId;
if (_iframeId == null) {
_iframeId = defaultIframeId;
}
if (_DEV_) console.log('postMessageToParentWindow', type, _iframeId, payload);
window.parent.postMessage({ window.parent.postMessage({
type, type,
iframeId: _iframeId,
payload, payload,
}, '*'); }, '*');
} }

View File

@ -93,9 +93,16 @@ html {
&.embed { &.embed {
background-color: transparent; background-color: transparent;
overflow: hidden;
} }
} }
html.embed,
html.embed body,
html.embed #misskey_app {
height: 100%;
}
html._themeChanging_ { html._themeChanging_ {
&, * { &, * {
transition: background 1s ease, border 1s ease !important; transition: background 1s ease, border 1s ease !important;

View File

@ -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>

View File

@ -4,15 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div <div :class="$style.root">
ref="rootEl"
:class="isEmbed ? [
$style.rootForEmbedPage,
{
[$style.rounded]: embedRounded,
}
] : [$style.root]"
>
<div style="container-type: inline-size;"> <div style="container-type: inline-size;">
<RouterView/> <RouterView/>
</div> </div>
@ -22,15 +14,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <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 XCommon from './_common_/common.vue';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { instanceName } from '@/config.js'; import { instanceName } from '@/config.js';
import { mainRouter } from '@/router/main.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'); const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
@ -50,35 +38,7 @@ provideMetadataReceiver((metadataGetter) => {
}); });
provideReactiveMetadata(pageMetadata); provideReactiveMetadata(pageMetadata);
//#region Embed Style document.documentElement.style.overflowY = 'scroll';
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';
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>
@ -86,16 +46,4 @@ if (isEmbed) {
min-height: 100dvh; min-height: 100dvh;
box-sizing: border-box; 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> </style>