feat: ノート・ユーザTL埋め込み
This commit is contained in:
		| @@ -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(); | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 kakkokari-gtyih
					kakkokari-gtyih