feat: ノート・ユーザTL埋め込み
This commit is contained in:
parent
f80c5d26b5
commit
e1a541d60b
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -77,7 +77,7 @@ html
|
|||
script
|
||||
include ../boot.js
|
||||
|
||||
body
|
||||
body(class=embed && 'embed')
|
||||
noscript: p
|
||||
| JavaScriptを有効にしてください
|
||||
br
|
||||
|
|
|
@ -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 (isEmbedPage()) {
|
||||
const bootOptions: Partial<CommonBootOptions> = {};
|
||||
|
||||
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' });
|
||||
}
|
||||
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();
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
</style>
|
33
packages/frontend/src/pages/embed/note.vue
Normal file
33
packages/frontend/src/pages/embed/note.vue
Normal 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>
|
57
packages/frontend/src/pages/embed/user-timeline.vue
Normal file
57
packages/frontend/src/pages/embed/user-timeline.vue
Normal 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>
|
|
@ -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')),
|
||||
|
|
|
@ -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,
|
||||
}, '*');
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
113
packages/frontend/src/ui/embed.vue
Normal file
113
packages/frontend/src/ui/embed.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue