summaryrefslogtreecommitdiff
path: root/packages/frontend-embed
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-09-09 20:57:36 +0900
committerGitHub <noreply@github.com>2024-09-09 20:57:36 +0900
commit2cbe1d1210a5745787f37069ecb59b8f6c03c224 (patch)
tree9acb1e675d2ae85f7f1f0f34f6acdc4965bff3f9 /packages/frontend-embed
parentrefactor(misskey-js): warnを除去 (#14520) (diff)
downloadmisskey-2cbe1d1210a5745787f37069ecb59b8f6c03c224.tar.gz
misskey-2cbe1d1210a5745787f37069ecb59b8f6c03c224.tar.bz2
misskey-2cbe1d1210a5745787f37069ecb59b8f6c03c224.zip
feat(frontend): ノート・ユーザータイムライン埋め込み (#13929)
* fix * navhookをbootに移動 * サーバーサイドのbootも分けるように * 埋め込みページかどうかの判定は最初の一回だけに * tooltipは出せるように * fix design * 埋め込み独自のtooltipを削除 * ロジックの分岐が多かったMkNoteDetailedを分離 * fix indent * プレビュー用iframeにフォーカスが当たるのを修正 * popupの制御を出す側で行うように * パラメータが逆になっていたのを修正 * Update MkEmbedCodeGenDialog.vue * fix * eliminate misskey-js lint warns * fix * add appropriate attributes to embed html * enhance: サーバーサイドのembed系をさらに分離 * enhance: embed routerを分離(route定義をboot時に変更できるようにする改修を含む) * type * lint * fix indent * server-side styleを完全に分離 * Revert "refactor: 画面サイズのしきい値をconstにまとめる" This reverts commit 05ca36f400889456981e89489ae0ae242fa09b67. * fix * revert all changes in base.pug * embedドメインをまとめた * embedドメインをまとめた * prevent calling contextmenu in embed page by stopping at the caller * fix import * fix import * improve directory structure * fix import * register timeline ui as a container * wa- * rename * wa- * Update EmMediaList.vue * Update EmMediaList.vue * Update EmMediaList.vue * Update EmMediaImage.vue * Update EmNote.vue * revert mkmedialist changes * 戻し漏れ * wip * tweak embed media ui * revert original media components * Update boot.embed.js * rename * wip * Update MkNote.vue * wip * Update MkSubNoteContent.vue * Update EmNote.vue * Update packages/frontend/src/router/definition.ts * Revert "Update packages/frontend/src/router/definition.ts" This reverts commit 937ae44521cdb0f250796943b20142b65f8ed944. * refactor EmMediaImage * fix import * remove unused imports * Update router.ts * wip * Update boot.ts * wip * wip * wip * wip * Update EmNote.vue * Update EmNote.vue * Create EmA.vue * Create EmAvatar.vue * Update EmAvatar.vue * wip * wip * wip * Create EmImgWithBlurhash.vue * Update EmImgWithBlurhash.vue * Create EmPagination.vue * wip * Update boot.ts * wip * wip * wi@p * wip * wip * wiop * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update boot.ts * wip * Update MkMisskeyFlavoredMarkdown.ts * wip * wip * wip * wip * wip * Update post-message.ts * wip * Update EmNoteDetailed.vue * Update EmNoteDetailed.vue * Create instance.ts * Update EmNoteDetailed.vue * wip * Update EmNoteDetailed.vue * wip * wip * wip * Update pnpm-lock.yaml * wip * wip * wp * wip * Update ClientServerService.ts * wip * Update boot.ts * Update vite.config.local-dev.ts * Update vite.config.ts * Create index.html * wa- * wip * Update boot.ts * wip * wip * wip * wip * wip * wip * wip * wip * wip * Create EmLink.vue * Create EmMention.vue * Update EmMfm.ts * wip * wip * wip * wip * Update vite.config.ts * Update boot.ts * Update EmA.vue * うぃp * wip * wip * Create EmError.vue * wip * Update MkEmbedCodeGenDialog.vue * Update EmNote.vue * wip * wip * Update user-timeline.vue * Update check-spdx-license-id.yml * wip * wip * style(frontend-shared): lint fixes on build.js * fix(frontend-shared): include `*.{js,json}` files in js-built * wip * use alias * refactor * refactor * Update scroll.ts * refactor * refactor * refactor * wip * wip * wip * wip * Update roles.vue * Update branding.vue * wip * wip * wip * Update page.vue * wip * fix import * add missing css variables * 絵文字をtwemojiに変更 クライアントデフォルトにあわせるため * force empoll readonly * fix compiler error * fix broken imports * tweak button style * run api extractor * fix storybook theme preloads * fix storybook instance imports * Update preview.ts * Update preview.ts * Update preview.ts * Revert "Update preview.ts" This reverts commit 12bab1c6fbd3baf753515df760ff19d027b85155. * Revert "Update preview.ts" This reverts commit 5c0ce01dbdf2194ffe94aba950f747a9968f29c4. * Revert "Update preview.ts" This reverts commit f4863524d7e5ca0f25470808849c24a72bea000a. * Revert "fix storybook instance imports" This reverts commit ed8eabb246edf731d31adffbe3c77c539e53ae9e. * Revert "wip" This reverts commit d3c1926519878155193a1654f49141e515d49683. * Revert "Update page.vue" This reverts commit 27c7900b0c1ae296b56075e8a9c22585d9cd744b. * Revert "Update branding.vue" This reverts commit c08ccb65ba66774c3e2b3dcfc6153004b5c0aa16. * Revert "Update roles.vue" This reverts commit 1488b670660cb1803d17d8f5c78f2d79e59fa52d. * Revert "wip" This reverts commit aab1c769814b08c257cad3025422a0eea3bfba4f. * refactor: use common media proxy * fix imports * fix * fix: MediaProxyの初期化を保証する(storybook対策?) * enhance(frontend-embed): improve embedParams provide * fix(backend): MK_DEV_PREFER=backendのときにembed viteが読み込めないのを修正 * fix * embed-pageを共通化 * fix import * fix import * fix import * const.jsを共通化 (たぶんrevertしすぎた) * fix type error * fix duplicated import * fix lint * fix * コメントとして残す * sharedとembedをlint対象にする * lint * attempt to fix eslint (frontend-shared) * lint fixes --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
Diffstat (limited to 'packages/frontend-embed')
-rw-r--r--packages/frontend-embed/.gitignore1
-rw-r--r--packages/frontend-embed/@types/global.d.ts23
-rw-r--r--packages/frontend-embed/@types/theme.d.ts12
-rw-r--r--packages/frontend-embed/assets/dummy.pngbin0 -> 6285 bytes
-rw-r--r--packages/frontend-embed/eslint.config.js95
-rw-r--r--packages/frontend-embed/package.json85
-rw-r--r--packages/frontend-embed/src/boot.ts114
-rw-r--r--packages/frontend-embed/src/components/EmA.vue21
-rw-r--r--packages/frontend-embed/src/components/EmAcct.vue24
-rw-r--r--packages/frontend-embed/src/components/EmAvatar.vue250
-rw-r--r--packages/frontend-embed/src/components/EmCustomEmoji.vue101
-rw-r--r--packages/frontend-embed/src/components/EmEmoji.vue26
-rw-r--r--packages/frontend-embed/src/components/EmError.vue43
-rw-r--r--packages/frontend-embed/src/components/EmImgWithBlurhash.vue240
-rw-r--r--packages/frontend-embed/src/components/EmInstanceTicker.vue87
-rw-r--r--packages/frontend-embed/src/components/EmLink.vue40
-rw-r--r--packages/frontend-embed/src/components/EmLoading.vue112
-rw-r--r--packages/frontend-embed/src/components/EmMediaBanner.vue55
-rw-r--r--packages/frontend-embed/src/components/EmMediaImage.vue154
-rw-r--r--packages/frontend-embed/src/components/EmMediaList.vue146
-rw-r--r--packages/frontend-embed/src/components/EmMediaVideo.vue64
-rw-r--r--packages/frontend-embed/src/components/EmMention.vue46
-rw-r--r--packages/frontend-embed/src/components/EmMfm.ts461
-rw-r--r--packages/frontend-embed/src/components/EmNote.vue609
-rw-r--r--packages/frontend-embed/src/components/EmNoteDetailed.vue486
-rw-r--r--packages/frontend-embed/src/components/EmNoteHeader.vue104
-rw-r--r--packages/frontend-embed/src/components/EmNoteSimple.vue105
-rw-r--r--packages/frontend-embed/src/components/EmNoteSub.vue149
-rw-r--r--packages/frontend-embed/src/components/EmNotes.vue48
-rw-r--r--packages/frontend-embed/src/components/EmPagination.vue504
-rw-r--r--packages/frontend-embed/src/components/EmPoll.vue82
-rw-r--r--packages/frontend-embed/src/components/EmReactionIcon.vue23
-rw-r--r--packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue99
-rw-r--r--packages/frontend-embed/src/components/EmReactionsViewer.vue104
-rw-r--r--packages/frontend-embed/src/components/EmSubNoteContent.vue113
-rw-r--r--packages/frontend-embed/src/components/EmTime.vue107
-rw-r--r--packages/frontend-embed/src/components/EmTimelineContainer.vue39
-rw-r--r--packages/frontend-embed/src/components/EmUrl.vue96
-rw-r--r--packages/frontend-embed/src/components/EmUserName.vue21
-rw-r--r--packages/frontend-embed/src/components/I18n.vue51
-rw-r--r--packages/frontend-embed/src/config.ts18
-rw-r--r--packages/frontend-embed/src/custom-emojis.ts61
-rw-r--r--packages/frontend-embed/src/di.ts15
-rw-r--r--packages/frontend-embed/src/i18n.ts15
-rw-r--r--packages/frontend-embed/src/index.html36
-rw-r--r--packages/frontend-embed/src/misskey-api.ts99
-rw-r--r--packages/frontend-embed/src/pages/clip.vue140
-rw-r--r--packages/frontend-embed/src/pages/not-found.vue24
-rw-r--r--packages/frontend-embed/src/pages/note.vue48
-rw-r--r--packages/frontend-embed/src/pages/tag.vue125
-rw-r--r--packages/frontend-embed/src/pages/user-timeline.vue138
-rw-r--r--packages/frontend-embed/src/post-message.ts49
-rw-r--r--packages/frontend-embed/src/server-metadata.ts15
-rw-r--r--packages/frontend-embed/src/style.scss453
-rw-r--r--packages/frontend-embed/src/theme.ts102
-rw-r--r--packages/frontend-embed/src/to-be-shared/collapsed.ts22
-rw-r--r--packages/frontend-embed/src/to-be-shared/intl-const.ts50
-rw-r--r--packages/frontend-embed/src/to-be-shared/is-link.ts12
-rw-r--r--packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts82
-rw-r--r--packages/frontend-embed/src/ui.vue96
-rw-r--r--packages/frontend-embed/src/utils.ts23
-rw-r--r--packages/frontend-embed/src/workers/draw-blurhash.ts22
-rw-r--r--packages/frontend-embed/src/workers/test-webgl2.ts14
-rw-r--r--packages/frontend-embed/src/workers/tsconfig.json5
-rw-r--r--packages/frontend-embed/tsconfig.json53
-rw-r--r--packages/frontend-embed/vite.config.local-dev.ts96
-rw-r--r--packages/frontend-embed/vite.config.ts156
-rw-r--r--packages/frontend-embed/vite.json5.ts48
-rw-r--r--packages/frontend-embed/vue-shims.d.ts6
69 files changed, 6963 insertions, 0 deletions
diff --git a/packages/frontend-embed/.gitignore b/packages/frontend-embed/.gitignore
new file mode 100644
index 0000000000..1aa0ac14e8
--- /dev/null
+++ b/packages/frontend-embed/.gitignore
@@ -0,0 +1 @@
+/storybook-static
diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts
new file mode 100644
index 0000000000..1025d1bedb
--- /dev/null
+++ b/packages/frontend-embed/@types/global.d.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+type FIXME = any;
+
+declare const _LANGS_: string[][];
+declare const _VERSION_: string;
+declare const _ENV_: string;
+declare const _DEV_: boolean;
+declare const _PERF_PREFIX_: string;
+declare const _DATA_TRANSFER_DRIVE_FILE_: string;
+declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
+declare const _DATA_TRANSFER_DECK_COLUMN_: string;
+
+// for dev-mode
+declare const _LANGS_FULL_: string[][];
+
+// TagCanvas
+interface Window {
+ TagCanvas: any;
+}
diff --git a/packages/frontend-embed/@types/theme.d.ts b/packages/frontend-embed/@types/theme.d.ts
new file mode 100644
index 0000000000..6ac1037493
--- /dev/null
+++ b/packages/frontend-embed/@types/theme.d.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+declare module '@@/themes/*.json5' {
+ import { Theme } from '@/theme.js';
+
+ const theme: Theme;
+
+ export default theme;
+}
diff --git a/packages/frontend-embed/assets/dummy.png b/packages/frontend-embed/assets/dummy.png
new file mode 100644
index 0000000000..39332b0c1b
--- /dev/null
+++ b/packages/frontend-embed/assets/dummy.png
Binary files differ
diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js
new file mode 100644
index 0000000000..dd8f03dac5
--- /dev/null
+++ b/packages/frontend-embed/eslint.config.js
@@ -0,0 +1,95 @@
+import globals from 'globals';
+import tsParser from '@typescript-eslint/parser';
+import parser from 'vue-eslint-parser';
+import pluginVue from 'eslint-plugin-vue';
+import pluginMisskey from '@misskey-dev/eslint-plugin';
+import sharedConfig from '../shared/eslint.config.js';
+
+export default [
+ ...sharedConfig,
+ {
+ files: ['src/**/*.vue'],
+ ...pluginMisskey.configs.typescript,
+ },
+ ...pluginVue.configs['flat/recommended'],
+ {
+ files: ['src/**/*.{ts,vue}'],
+ languageOptions: {
+ globals: {
+ ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
+ ...globals.browser,
+
+ // Node.js
+ module: false,
+ require: false,
+ __dirname: false,
+
+ // Misskey
+ _DEV_: false,
+ _LANGS_: false,
+ _VERSION_: false,
+ _ENV_: false,
+ _PERF_PREFIX_: false,
+ _DATA_TRANSFER_DRIVE_FILE_: false,
+ _DATA_TRANSFER_DRIVE_FOLDER_: false,
+ _DATA_TRANSFER_DECK_COLUMN_: false,
+ },
+ parser,
+ parserOptions: {
+ extraFileExtensions: ['.vue'],
+ parser: tsParser,
+ project: ['./tsconfig.json'],
+ sourceType: 'module',
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ rules: {
+ '@typescript-eslint/no-empty-interface': ['error', {
+ allowSingleExtends: true,
+ }],
+ // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
+ // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
+ 'id-denylist': ['error', 'window', 'e'],
+ 'no-shadow': ['warn'],
+ 'vue/attributes-order': ['error', {
+ alphabetical: false,
+ }],
+ 'vue/no-use-v-if-with-v-for': ['error', {
+ allowUsingIterationVar: false,
+ }],
+ 'vue/no-ref-as-operand': 'error',
+ 'vue/no-multi-spaces': ['error', {
+ ignoreProperties: false,
+ }],
+ 'vue/no-v-html': 'warn',
+ 'vue/order-in-components': 'error',
+ 'vue/html-indent': ['warn', 'tab', {
+ attribute: 1,
+ baseIndent: 0,
+ closeBracket: 0,
+ alignAttributesVertically: true,
+ ignores: [],
+ }],
+ 'vue/html-closing-bracket-spacing': ['warn', {
+ startTag: 'never',
+ endTag: 'never',
+ selfClosingTag: 'never',
+ }],
+ 'vue/multi-word-component-names': 'warn',
+ 'vue/require-v-for-key': 'warn',
+ 'vue/no-unused-components': 'warn',
+ 'vue/no-unused-vars': 'warn',
+ 'vue/no-dupe-keys': 'warn',
+ 'vue/valid-v-for': 'warn',
+ 'vue/return-in-computed-property': 'warn',
+ 'vue/no-setup-props-reactivity-loss': 'warn',
+ 'vue/max-attributes-per-line': 'off',
+ 'vue/html-self-closing': 'off',
+ 'vue/singleline-html-element-content-newline': 'off',
+ 'vue/v-on-event-hyphenation': ['error', 'never', {
+ autofix: true,
+ }],
+ 'vue/attribute-hyphenation': ['error', 'never'],
+ },
+ },
+];
diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json
new file mode 100644
index 0000000000..a65d6ab657
--- /dev/null
+++ b/packages/frontend-embed/package.json
@@ -0,0 +1,85 @@
+{
+ "name": "frontend-embed",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "watch": "vite",
+ "dev": "vite --config vite.config.local-dev.ts --debug hmr",
+ "build": "vite build",
+ "typecheck": "vue-tsc --noEmit",
+ "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
+ "lint": "pnpm typecheck && pnpm eslint"
+ },
+ "dependencies": {
+ "@discordapp/twemoji": "15.0.3",
+ "@github/webauthn-json": "2.1.1",
+ "@rollup/plugin-json": "6.1.0",
+ "@rollup/plugin-replace": "5.0.7",
+ "@rollup/pluginutils": "5.1.0",
+ "@tabler/icons-webfont": "3.3.0",
+ "@twemoji/parser": "15.1.1",
+ "@vitejs/plugin-vue": "5.1.0",
+ "@vue/compiler-sfc": "3.4.37",
+ "astring": "1.8.6",
+ "buraha": "0.0.1",
+ "compare-versions": "6.1.1",
+ "date-fns": "2.30.0",
+ "escape-regexp": "0.0.1",
+ "estree-walker": "3.0.3",
+ "eventemitter3": "5.0.1",
+ "idb-keyval": "6.2.1",
+ "is-file-animated": "1.0.2",
+ "mfm-js": "0.24.0",
+ "misskey-js": "workspace:*",
+ "frontend-shared": "workspace:*",
+ "punycode": "2.3.1",
+ "rollup": "4.19.1",
+ "sanitize-html": "2.13.0",
+ "sass": "1.77.8",
+ "shiki": "1.12.0",
+ "strict-event-emitter-types": "2.0.0",
+ "throttle-debounce": "5.0.2",
+ "tinycolor2": "1.6.0",
+ "tsc-alias": "1.8.10",
+ "tsconfig-paths": "4.2.0",
+ "typescript": "5.5.4",
+ "uuid": "10.0.0",
+ "json5": "2.2.3",
+ "vite": "5.3.5",
+ "vue": "3.4.37"
+ },
+ "devDependencies": {
+ "@misskey-dev/summaly": "5.1.0",
+ "@testing-library/vue": "8.1.0",
+ "@types/escape-regexp": "0.0.3",
+ "@types/estree": "1.0.5",
+ "@types/micromatch": "4.0.9",
+ "@types/node": "20.14.12",
+ "@types/punycode": "2.1.4",
+ "@types/sanitize-html": "2.11.0",
+ "@types/throttle-debounce": "5.0.2",
+ "@types/tinycolor2": "1.4.6",
+ "@types/uuid": "10.0.0",
+ "@types/ws": "8.5.11",
+ "@typescript-eslint/eslint-plugin": "7.17.0",
+ "@typescript-eslint/parser": "7.17.0",
+ "@vitest/coverage-v8": "1.6.0",
+ "@vue/runtime-core": "3.4.37",
+ "acorn": "8.12.1",
+ "cross-env": "7.0.3",
+ "eslint-plugin-import": "2.29.1",
+ "eslint-plugin-vue": "9.27.0",
+ "fast-glob": "3.3.2",
+ "happy-dom": "10.0.3",
+ "intersection-observer": "0.12.2",
+ "micromatch": "4.0.7",
+ "msw": "2.3.4",
+ "nodemon": "3.1.4",
+ "prettier": "3.3.3",
+ "start-server-and-test": "2.0.4",
+ "vite-plugin-turbosnap": "1.0.3",
+ "vue-component-type-helpers": "2.0.29",
+ "vue-eslint-parser": "9.4.3",
+ "vue-tsc": "2.0.29"
+ }
+}
diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts
new file mode 100644
index 0000000000..4676baa905
--- /dev/null
+++ b/packages/frontend-embed/src/boot.ts
@@ -0,0 +1,114 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+// https://vitejs.dev/config/build-options.html#build-modulepreload
+import 'vite/modulepreload-polyfill';
+
+import '@tabler/icons-webfont/dist/tabler-icons.scss';
+
+import '@/style.scss';
+import { createApp, defineAsyncComponent } from 'vue';
+import lightTheme from '@@/themes/l-light.json5';
+import darkTheme from '@@/themes/d-dark.json5';
+import { MediaProxy } from '@@/js/media-proxy.js';
+import { applyTheme } from './theme.js';
+import { fetchCustomEmojis } from './custom-emojis.js';
+import { DI } from './di.js';
+import { serverMetadata } from './server-metadata.js';
+import { url } from './config.js';
+import { parseEmbedParams } from '@@/js/embed-page.js';
+import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
+
+console.info('Misskey Embed');
+
+const params = new URLSearchParams(location.search);
+const embedParams = parseEmbedParams(params);
+
+console.info(embedParams);
+
+if (embedParams.colorMode === 'dark') {
+ applyTheme(darkTheme);
+} else if (embedParams.colorMode === 'light') {
+ applyTheme(lightTheme);
+} else {
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ applyTheme(darkTheme);
+ } else {
+ applyTheme(lightTheme);
+ }
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
+ if (mql.matches) {
+ applyTheme(darkTheme);
+ } else {
+ applyTheme(lightTheme);
+ }
+ });
+}
+
+// サイズの制限
+document.documentElement.style.maxWidth = '500px';
+
+// iframeIdの設定
+function setIframeIdHandler(event: MessageEvent) {
+ if (event.data?.type === 'misskey:embedParent:registerIframeId' && event.data.payload?.iframeId != null) {
+ setIframeId(event.data.payload.iframeId);
+ window.removeEventListener('message', setIframeIdHandler);
+ }
+}
+
+window.addEventListener('message', setIframeIdHandler);
+
+try {
+ await fetchCustomEmojis();
+} catch (err) { /* empty */ }
+
+const app = createApp(
+ defineAsyncComponent(() => import('@/ui.vue')),
+);
+
+app.provide(DI.mediaProxy, new MediaProxy(serverMetadata, url));
+
+app.provide(DI.embedParams, embedParams);
+
+// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210
+// なぜか2回実行されることがあるため、mountするdivを1つに制限する
+const rootEl = ((): HTMLElement => {
+ const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
+
+ const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID);
+
+ if (currentRoot) {
+ console.warn('multiple import detected');
+ return currentRoot;
+ }
+
+ const root = document.createElement('div');
+ root.id = MISSKEY_MOUNT_DIV_ID;
+ document.body.appendChild(root);
+ return root;
+})();
+
+postMessageToParentWindow('misskey:embed:ready');
+
+app.mount(rootEl);
+
+// boot.jsのやつを解除
+window.onerror = null;
+window.onunhandledrejection = null;
+
+removeSplash();
+
+function removeSplash() {
+ const splash = document.getElementById('splash');
+ if (splash) {
+ splash.style.opacity = '0';
+ splash.style.pointerEvents = 'none';
+
+ // transitionendイベントが発火しない場合があるため
+ window.setTimeout(() => {
+ splash.remove();
+ }, 1000);
+ }
+}
diff --git a/packages/frontend-embed/src/components/EmA.vue b/packages/frontend-embed/src/components/EmA.vue
new file mode 100644
index 0000000000..1c236b9a35
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmA.vue
@@ -0,0 +1,21 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="to" target="_blank" rel="noopener">
+ <slot></slot>
+</a>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+ to: string;
+ activeClass?: null | string;
+}>(), {
+ activeClass: null,
+});
+</script>
diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue
new file mode 100644
index 0000000000..07315e6a8b
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmAcct.vue
@@ -0,0 +1,24 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<span>
+ <span>@{{ user.username }}</span>
+ <span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span>
+</span>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+import { toUnicode } from 'punycode/';
+import { host as hostRaw } from '@/config.js';
+
+defineProps<{
+ user: Misskey.entities.UserLite;
+ detail?: boolean;
+}>();
+
+const host = toUnicode(hostRaw);
+</script>
diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue
new file mode 100644
index 0000000000..58c35c8ef0
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmAvatar.vue
@@ -0,0 +1,250 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component :is="link ? EmA : 'span'" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat }]">
+ <EmImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
+ <div v-if="user.isCat" :class="[$style.ears]">
+ <div :class="$style.earLeft">
+ <div v-if="false" :class="$style.layer">
+ <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+ <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+ <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+ </div>
+ </div>
+ <div :class="$style.earRight">
+ <div v-if="false" :class="$style.layer">
+ <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+ <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+ <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
+ </div>
+ </div>
+ </div>
+ <img
+ v-for="decoration in user.avatarDecorations"
+ :class="[$style.decoration]"
+ :src="getDecorationUrl(decoration)"
+ :style="{
+ rotate: getDecorationAngle(decoration),
+ scale: getDecorationScale(decoration),
+ translate: getDecorationOffset(decoration),
+ }"
+ alt=""
+ >
+</component>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmImgWithBlurhash from './EmImgWithBlurhash.vue';
+import EmA from './EmA.vue';
+import { userPage } from '@/utils.js';
+
+const props = withDefaults(defineProps<{
+ user: Misskey.entities.User;
+ link?: boolean;
+ preview?: boolean;
+ indicator?: boolean;
+}>(), {
+ link: false,
+ preview: false,
+ indicator: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'click', v: MouseEvent): void;
+}>();
+
+const bound = computed(() => props.link
+ ? { to: userPage(props.user) }
+ : {});
+
+const url = computed(() => {
+ if (props.user.avatarUrl == null) return null;
+ return props.user.avatarUrl;
+});
+
+function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+ return decoration.url;
+}
+
+function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+ const angle = decoration.angle ?? 0;
+ return angle === 0 ? undefined : `${angle * 360}deg`;
+}
+
+function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+ const scaleX = decoration.flipH ? -1 : 1;
+ return scaleX === 1 ? undefined : `${scaleX} 1`;
+}
+
+function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) {
+ const offsetX = decoration.offsetX ?? 0;
+ const offsetY = decoration.offsetY ?? 0;
+ return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`;
+}
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ display: inline-block;
+ vertical-align: bottom;
+ flex-shrink: 0;
+ border-radius: 100%;
+ line-height: 16px;
+}
+
+.inner {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: 0;
+ border-radius: 100%;
+ z-index: 1;
+ overflow: clip;
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+}
+
+.indicator {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ width: 20%;
+ height: 20%;
+}
+
+.cat {
+ > .ears {
+ contain: strict;
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 100%;
+ height: 100%;
+ padding: 50%;
+ pointer-events: none;
+
+ > .earLeft,
+ > .earRight {
+ contain: strict;
+ display: inline-block;
+ height: 50%;
+ width: 50%;
+ background: currentColor;
+
+ &::after {
+ contain: strict;
+ content: '';
+ display: block;
+ width: 60%;
+ height: 60%;
+ margin: 20%;
+ background: #df548f;
+ }
+
+ > .layer {
+ contain: strict;
+ position: absolute;
+ top: 0;
+ width: 280%;
+ height: 280%;
+
+ > .plot {
+ contain: strict;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ clip-path: path('M0 0H1V1H0z');
+ transform: scale(32767);
+ transform-origin: 0 0;
+ opacity: 0.5;
+
+ &:first-child {
+ opacity: 1;
+ }
+
+ &:last-child {
+ opacity: calc(1 / 3);
+ }
+ }
+ }
+ }
+
+ > .earLeft {
+ transform: rotate(37.5deg) skew(30deg);
+
+ &, &::after {
+ border-radius: 25% 75% 75%;
+ }
+
+ > .layer {
+ left: 0;
+ transform:
+ skew(-30deg)
+ rotate(-37.5deg)
+ translate(-2.82842712475%, /* -2 * sqrt(2) */
+ -38.5857864376%); /* 40 - 2 * sqrt(2) */
+
+ > .plot {
+ background-position: 20% 10%; /* ~= 37.5deg */
+
+ &:first-child {
+ background-position-x: 21%;
+ }
+
+ &:last-child {
+ background-position-y: 11%;
+ }
+ }
+ }
+ }
+
+ > .earRight {
+ transform: rotate(-37.5deg) skew(-30deg);
+
+ &, &::after {
+ border-radius: 75% 25% 75% 75%;
+ }
+
+ > .layer {
+ right: 0;
+ transform:
+ skew(30deg)
+ rotate(37.5deg)
+ translate(2.82842712475%, /* 2 * sqrt(2) */
+ -38.5857864376%); /* 40 - 2 * sqrt(2) */
+
+ > .plot {
+ position: absolute;
+ background-position: 80% 10%; /* ~= 37.5deg */
+
+ &:first-child {
+ background-position-x: 79%;
+ }
+
+ &:last-child {
+ background-position-y: 11%;
+ }
+ }
+ }
+ }
+ }
+}
+
+.decoration {
+ position: absolute;
+ z-index: 1;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ pointer-events: none;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmCustomEmoji.vue b/packages/frontend-embed/src/components/EmCustomEmoji.vue
new file mode 100644
index 0000000000..e4149cf363
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmCustomEmoji.vue
@@ -0,0 +1,101 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<img
+ v-if="errored && fallbackToImage"
+ :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
+ src="/client-assets/dummy.png"
+ :title="alt"
+/>
+<span v-else-if="errored">:{{ customEmojiName }}:</span>
+<img
+ v-else
+ :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
+ :src="url"
+ :alt="alt"
+ :title="alt"
+ decoding="async"
+ @error="errored = true"
+ @load="errored = false"
+/>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from 'vue';
+import { customEmojisMap } from '@/custom-emojis.js';
+
+import { DI } from '@/di.js';
+
+const mediaProxy = inject(DI.mediaProxy)!;
+
+const props = defineProps<{
+ name: string;
+ normal?: boolean;
+ noStyle?: boolean;
+ host?: string | null;
+ url?: string;
+ useOriginalSize?: boolean;
+ menu?: boolean;
+ menuReaction?: boolean;
+ fallbackToImage?: boolean;
+}>();
+
+const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
+const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
+
+const rawUrl = computed(() => {
+ if (props.url) {
+ return props.url;
+ }
+ if (isLocal.value) {
+ return customEmojisMap.get(customEmojiName.value)?.url ?? null;
+ }
+ return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`;
+});
+
+const url = computed(() => {
+ if (rawUrl.value == null) return undefined;
+
+ const proxied =
+ (rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value))
+ ? rawUrl.value
+ : mediaProxy.getProxiedImageUrl(
+ rawUrl.value,
+ props.useOriginalSize ? undefined : 'emoji',
+ false,
+ true,
+ );
+ return proxied;
+});
+
+const alt = computed(() => `:${customEmojiName.value}:`);
+const errored = ref(url.value == null);
+</script>
+
+<style lang="scss" module>
+.root {
+ height: 2em;
+ vertical-align: middle;
+ transition: transform 0.2s ease;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+}
+
+.normal {
+ height: 1.25em;
+ vertical-align: -0.25em;
+
+ &:hover {
+ transform: none;
+ }
+}
+
+.noStyle {
+ height: auto !important;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmEmoji.vue b/packages/frontend-embed/src/components/EmEmoji.vue
new file mode 100644
index 0000000000..224979707b
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmEmoji.vue
@@ -0,0 +1,26 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<img :class="$style.root" :src="url" :alt="props.emoji" decoding="async"/>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { char2twemojiFilePath } from '@@/js/emoji-base.js';
+
+const props = defineProps<{
+ emoji: string;
+}>();
+
+const url = computed(() => char2twemojiFilePath(props.emoji));
+</script>
+
+<style lang="scss" module>
+.root {
+ height: 1.25em;
+ vertical-align: -0.25em;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmError.vue b/packages/frontend-embed/src/components/EmError.vue
new file mode 100644
index 0000000000..d376b29a7f
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmError.vue
@@ -0,0 +1,43 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+ <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
+ <button class="_buttonGray _buttonRounded" :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { i18n } from '@/i18n.js';
+
+const emit = defineEmits<{
+ (ev: 'retry'): void;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 32px;
+ text-align: center;
+ align-items: center;
+}
+
+.text {
+ margin: 0 0 8px 0;
+}
+
+.button {
+ margin: 0 auto;
+}
+
+.img {
+ vertical-align: bottom;
+ width: 128px;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue
new file mode 100644
index 0000000000..d19cd08d0a
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue
@@ -0,0 +1,240 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''">
+ <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
+ <img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
+</div>
+</template>
+
+<script lang="ts">
+import DrawBlurhash from '@/workers/draw-blurhash?worker';
+import TestWebGL2 from '@/workers/test-webgl2?worker';
+import { WorkerMultiDispatch } from '@/to-be-shared/worker-multi-dispatch.js';
+import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
+
+const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
+ // テスト環境で Web Worker インスタンスは作成できない
+ if (import.meta.env.MODE === 'test') {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 64;
+ resolve(canvas);
+ return;
+ }
+ const testWorker = new TestWebGL2();
+ testWorker.addEventListener('message', event => {
+ if (event.data.result) {
+ const workers = new WorkerMultiDispatch(
+ () => new DrawBlurhash(),
+ Math.min(navigator.hardwareConcurrency - 1, 4),
+ );
+ resolve(workers);
+ if (_DEV_) console.log('WebGL2 in worker is supported!');
+ } else {
+ const canvas = document.createElement('canvas');
+ canvas.width = 64;
+ canvas.height = 64;
+ resolve(canvas);
+ if (_DEV_) console.log('WebGL2 in worker is not supported...');
+ }
+ testWorker.terminate();
+ });
+});
+</script>
+
+<script lang="ts" setup>
+import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
+import { v4 as uuid } from 'uuid';
+import { render } from 'buraha';
+
+const props = withDefaults(defineProps<{
+ src?: string | null;
+ hash?: string | null;
+ alt?: string | null;
+ title?: string | null;
+ height?: number;
+ width?: number;
+ cover?: boolean;
+ forceBlurhash?: boolean;
+ onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
+}>(), {
+ src: null,
+ alt: '',
+ title: null,
+ height: 64,
+ width: 64,
+ cover: true,
+ forceBlurhash: false,
+ onlyAvgColor: false,
+});
+
+const viewId = uuid();
+const canvas = shallowRef<HTMLCanvasElement>();
+const root = shallowRef<HTMLDivElement>();
+const img = shallowRef<HTMLImageElement>();
+const loaded = ref(false);
+const canvasWidth = ref(64);
+const canvasHeight = ref(64);
+const imgWidth = ref(props.width);
+const imgHeight = ref(props.height);
+const bitmapTmp = ref<CanvasImageSource | undefined>();
+const hide = computed(() => !loaded.value || props.forceBlurhash);
+
+function waitForDecode() {
+ if (props.src != null && props.src !== '') {
+ nextTick()
+ .then(() => img.value?.decode())
+ .then(() => {
+ loaded.value = true;
+ }, error => {
+ console.log('Error occurred during decoding image', img.value, error);
+ });
+ } else {
+ loaded.value = false;
+ }
+}
+
+watch([() => props.width, () => props.height, root], () => {
+ const ratio = props.width / props.height;
+ if (ratio > 1) {
+ canvasWidth.value = Math.round(64 * ratio);
+ canvasHeight.value = 64;
+ } else {
+ canvasWidth.value = 64;
+ canvasHeight.value = Math.round(64 / ratio);
+ }
+
+ const clientWidth = root.value?.clientWidth ?? 300;
+ imgWidth.value = clientWidth;
+ imgHeight.value = Math.round(clientWidth / ratio);
+}, {
+ immediate: true,
+});
+
+function drawImage(bitmap: CanvasImageSource) {
+ // canvasがない(mountedされていない)場合はTmpに保存しておく
+ if (!canvas.value) {
+ bitmapTmp.value = bitmap;
+ return;
+ }
+
+ // canvasがあれば描画する
+ bitmapTmp.value = undefined;
+ const ctx = canvas.value.getContext('2d');
+ if (!ctx) return;
+ ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
+}
+
+function drawAvg() {
+ if (!canvas.value) return;
+
+ const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
+
+ const ctx = canvas.value.getContext('2d');
+ if (!ctx) return;
+
+ // avgColorでお茶をにごす
+ ctx.beginPath();
+ ctx.fillStyle = color;
+ ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
+}
+
+async function draw() {
+ if (import.meta.env.MODE === 'test' && props.hash == null) return;
+
+ drawAvg();
+
+ if (props.hash == null) return;
+
+ if (props.onlyAvgColor) return;
+
+ const work = await canvasPromise;
+ if (work instanceof WorkerMultiDispatch) {
+ work.postMessage(
+ {
+ id: viewId,
+ hash: props.hash,
+ },
+ undefined,
+ );
+ } else {
+ try {
+ render(props.hash, work);
+ drawImage(work);
+ } catch (error) {
+ console.error('Error occurred during drawing blurhash', error);
+ }
+ }
+}
+
+function workerOnMessage(event: MessageEvent) {
+ if (event.data.id !== viewId) return;
+ drawImage(event.data.bitmap as ImageBitmap);
+}
+
+canvasPromise.then(work => {
+ if (work instanceof WorkerMultiDispatch) {
+ work.addListener(workerOnMessage);
+ }
+
+ draw();
+});
+
+watch(() => props.src, () => {
+ waitForDecode();
+});
+
+watch(() => props.hash, () => {
+ draw();
+});
+
+onMounted(() => {
+ // drawImageがmountedより先に呼ばれている場合はここで描画する
+ if (bitmapTmp.value) {
+ drawImage(bitmapTmp.value);
+ }
+ waitForDecode();
+});
+
+onUnmounted(() => {
+ canvasPromise.then(work => {
+ if (work instanceof WorkerMultiDispatch) {
+ work.removeListener(workerOnMessage);
+ }
+ });
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ &.cover {
+ > .canvas,
+ > .img {
+ object-fit: cover;
+ }
+ }
+}
+
+.canvas,
+.img {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.canvas {
+ object-fit: contain;
+}
+
+.img {
+ object-fit: contain;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue
new file mode 100644
index 0000000000..eeeaee528e
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue
@@ -0,0 +1,87 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" :style="bg">
+ <img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
+ <div :class="$style.name">{{ instance.name }}</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
+
+import { DI } from '@/di.js';
+
+const serverMetadata = inject(DI.serverMetadata)!;
+const mediaProxy = inject(DI.mediaProxy)!;
+
+const props = defineProps<{
+ instance?: {
+ faviconUrl?: string | null
+ name?: string | null
+ themeColor?: string | null
+ }
+}>();
+
+// if no instance data is given, this is for the local instance
+const instance = props.instance ?? {
+ name: serverMetadata.name,
+ themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
+};
+
+const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico');
+
+const themeColor = serverMetadata.themeColor ?? '#777777';
+
+const bg = {
+ background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
+};
+</script>
+
+<style lang="scss" module>
+$height: 2ex;
+
+.root {
+ display: flex;
+ align-items: center;
+ height: $height;
+ border-radius: 4px 0 0 4px;
+ overflow: clip;
+ color: #fff;
+ text-shadow: /* .866 ≈ sin(60deg) */
+ 1px 0 1px #000,
+ .866px .5px 1px #000,
+ .5px .866px 1px #000,
+ 0 1px 1px #000,
+ -.5px .866px 1px #000,
+ -.866px .5px 1px #000,
+ -1px 0 1px #000,
+ -.866px -.5px 1px #000,
+ -.5px -.866px 1px #000,
+ 0 -1px 1px #000,
+ .5px -.866px 1px #000,
+ .866px -.5px 1px #000;
+ mask-image: linear-gradient(90deg,
+ rgb(0,0,0),
+ rgb(0,0,0) calc(100% - 16px),
+ rgba(0,0,0,0) 100%
+ );
+}
+
+.icon {
+ height: $height;
+ flex-shrink: 0;
+}
+
+.name {
+ margin-left: 4px;
+ line-height: 1;
+ font-size: 0.9em;
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: visible;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmLink.vue b/packages/frontend-embed/src/components/EmLink.vue
new file mode 100644
index 0000000000..319ad72399
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmLink.vue
@@ -0,0 +1,40 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component
+ :is="self ? EmA : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target"
+ :title="url"
+>
+ <slot></slot>
+ <i v-if="target === '_blank'" class="ti ti-external-link" :class="$style.icon"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import EmA from './EmA.vue';
+import { url as local } from '@/config.js';
+
+const props = withDefaults(defineProps<{
+ url: string;
+ rel?: null | string;
+}>(), {
+});
+
+const self = props.url.startsWith(local);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+
+const el = ref<HTMLElement | { $el: HTMLElement }>();
+
+</script>
+
+<style lang="scss" module>
+.icon {
+ padding-left: 2px;
+ font-size: .9em;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmLoading.vue b/packages/frontend-embed/src/components/EmLoading.vue
new file mode 100644
index 0000000000..49d8ace37b
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmLoading.vue
@@ -0,0 +1,112 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]">
+ <div :class="$style.container">
+ <svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+ <g transform="matrix(1.125,0,0,1.125,12,12)">
+ <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+ </g>
+ </svg>
+ <svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+ <g transform="matrix(1.125,0,0,1.125,12,12)">
+ <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+ </g>
+ </svg>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+ static?: boolean;
+ inline?: boolean;
+ colored?: boolean;
+ mini?: boolean;
+ em?: boolean;
+}>(), {
+ static: false,
+ inline: false,
+ colored: true,
+ mini: false,
+ em: false,
+});
+</script>
+
+<style lang="scss" module>
+@keyframes spinner {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.root {
+ padding: 32px;
+ text-align: center;
+ cursor: wait;
+
+ --size: 38px;
+
+ &.colored {
+ color: var(--accent);
+ }
+
+ &.inline {
+ display: inline;
+ padding: 0;
+ --size: 32px;
+ }
+
+ &.mini {
+ padding: 16px;
+ --size: 32px;
+ }
+
+ &.em {
+ display: inline-block;
+ vertical-align: middle;
+ padding: 0;
+ --size: 1em;
+ }
+}
+
+.container {
+ position: relative;
+ width: var(--size);
+ height: var(--size);
+ margin: 0 auto;
+}
+
+.spinner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--size);
+ height: var(--size);
+ fill-rule: evenodd;
+ clip-rule: evenodd;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-miterlimit: 1.5;
+}
+
+.bg {
+ opacity: 0.275;
+}
+
+.fg {
+ animation: spinner 0.5s linear infinite;
+
+ &.static {
+ animation-play-state: paused;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue
new file mode 100644
index 0000000000..435da238a4
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaBanner.vue
@@ -0,0 +1,55 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="href" target="_blank" :class="$style.root">
+ <div :class="$style.label">
+ <template v-if="media.type.startsWith('audio')"><i class="ti ti-music"></i> {{ i18n.ts.audio }}</template>
+ <template v-else><i class="ti ti-file"></i> {{ i18n.ts.file }}</template>
+ </div>
+ <div :class="$style.go">
+ <i class="ti ti-chevron-right"></i>
+ </div>
+</a>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+
+defineProps<{
+ media: Misskey.entities.DriveFile;
+ href: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: var(--margin);
+ margin-top: 4px;
+ border: 1px solid var(--inputBorder);
+ border-radius: var(--radius);
+ background-color: var(--panel);
+ transition: background-color .1s, border-color .1s;
+
+ &:hover {
+ text-decoration: none;
+ border-color: var(--inputBorderHover);
+ background-color: var(--buttonHoverBg);
+ }
+}
+
+.label {
+ font-size: .9em;
+}
+
+.go {
+ margin-left: auto;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue
new file mode 100644
index 0000000000..fe1aa5a877
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaImage.vue
@@ -0,0 +1,154 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[hide ? $style.hidden : $style.visible]" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
+ <a
+ :title="image.name"
+ :class="$style.imageContainer"
+ :href="href ?? image.url"
+ target="_blank"
+ rel="noopener"
+ >
+ <ImgWithBlurhash
+ :hash="image.blurhash"
+ :src="hide ? null : url"
+ :forceBlurhash="hide"
+ :cover="hide || cover"
+ :alt="image.comment || image.name"
+ :title="image.comment || image.name"
+ :width="image.properties.width"
+ :height="image.properties.height"
+ :style="hide ? 'filter: brightness(0.7);' : null"
+ />
+ </a>
+ <template v-if="hide">
+ <div :class="$style.hiddenText">
+ <div :class="$style.hiddenTextWrapper">
+ <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
+ <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ i18n.ts.image }}</b>
+ <span style="display: block;">{{ i18n.ts.clickToShow }}</span>
+ </div>
+ </div>
+ </template>
+ <div :class="$style.indicators">
+ <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
+ <div v-if="image.comment" :class="$style.indicator">ALT</div>
+ <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
+ </div>
+ <i v-if="!hide" class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import ImgWithBlurhash from '@/components/EmImgWithBlurhash.vue';
+import { i18n } from '@/i18n.js';
+
+const props = withDefaults(defineProps<{
+ image: Misskey.entities.DriveFile;
+ href?: string;
+ raw?: boolean;
+ cover?: boolean;
+}>(), {
+ cover: false,
+});
+
+const hide = ref(props.image.isSensitive);
+const darkMode = ref<boolean>(false); // TODO
+
+const url = computed(() => (props.raw)
+ ? props.image.url
+ : props.image.thumbnailUrl,
+);
+
+async function onclick(ev: MouseEvent) {
+ if (hide.value) {
+ ev.stopPropagation();
+ hide.value = false;
+ }
+}
+</script>
+
+<style lang="scss" module>
+.hidden {
+ position: relative;
+}
+
+.hiddenText {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+}
+
+.hide {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: var(--fg);
+ color: var(--accentLighten);
+ font-size: 12px;
+ opacity: .5;
+ padding: 5px 8px;
+ text-align: center;
+ cursor: pointer;
+ top: 12px;
+ right: 12px;
+}
+
+.hiddenTextWrapper {
+ display: table-cell;
+ text-align: center;
+ font-size: 0.8em;
+ color: #fff;
+}
+
+.visible {
+ position: relative;
+ //box-shadow: 0 0 0 1px var(--divider) inset;
+ background: var(--bg);
+ background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
+ background-size: 16px 16px;
+}
+
+.imageContainer {
+ display: block;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ background-position: center;
+ background-size: contain;
+ background-repeat: no-repeat;
+}
+
+.indicators {
+ display: inline-flex;
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ pointer-events: none;
+ opacity: .5;
+ gap: 6px;
+}
+
+.indicator {
+ /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */
+ background-color: black;
+ border-radius: 6px;
+ color: var(--accentLighten);
+ display: inline-block;
+ font-weight: bold;
+ font-size: 0.8em;
+ padding: 2px 5px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaList.vue b/packages/frontend-embed/src/components/EmMediaList.vue
new file mode 100644
index 0000000000..0b2d835abe
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaList.vue
@@ -0,0 +1,146 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <div v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :class="$style.banner">
+ <XBanner :media="media" :href="originalEntityUrl"/>
+ </div>
+ <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
+ <div
+ :class="[
+ $style.medias,
+ count === 1 ? [$style.n1] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
+ ]"
+ >
+ <div v-for="media in mediaList.filter(media => previewable(media))" :class="$style.media">
+ <XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.mediaInner" :video="media" :href="originalEntityUrl"/>
+ <XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :image="media" :raw="raw" :href="originalEntityUrl"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import XBanner from './EmMediaBanner.vue';
+import XImage from './EmMediaImage.vue';
+import XVideo from './EmMediaVideo.vue';
+import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
+
+const props = defineProps<{
+ mediaList: Misskey.entities.DriveFile[];
+ raw?: boolean;
+
+ /** 埋め込みページ用 親要素の正規URL */
+ originalEntityUrl: string;
+}>();
+
+const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
+
+const previewable = (file: Misskey.entities.DriveFile): boolean => {
+ if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
+ // FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
+ return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
+};
+</script>
+
+<style lang="scss" module>
+.container {
+ position: relative;
+ width: 100%;
+ margin-top: 4px;
+}
+
+.medias {
+ display: grid;
+ grid-gap: 8px;
+
+ height: 100%;
+ width: 100%;
+
+ &.n1 {
+ grid-template-rows: 1fr;
+
+ // default but fallback (expand)
+ min-height: 64px;
+ max-height: clamp(
+ 64px,
+ 50cqh,
+ min(360px, 50vh)
+ );
+
+ &.n116_9 {
+ min-height: initial;
+ max-height: initial;
+ aspect-ratio: 16 / 9; // fallback
+ }
+
+ &.n11_1{
+ min-height: initial;
+ max-height: initial;
+ aspect-ratio: 1 / 1; // fallback
+ }
+
+ &.n12_3 {
+ min-height: initial;
+ max-height: initial;
+ aspect-ratio: 2 / 3; // fallback
+ }
+ }
+
+ &.n2 {
+ aspect-ratio: 16/9;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr;
+ }
+
+ &.n3 {
+ aspect-ratio: 16/9;
+ grid-template-columns: 1fr 0.5fr;
+ grid-template-rows: 1fr 1fr;
+
+ > .media:nth-child(1) {
+ grid-row: 1 / 3;
+ }
+
+ > .media:nth-child(3) {
+ grid-column: 2 / 3;
+ grid-row: 2 / 3;
+ }
+ }
+
+ &.n4 {
+ aspect-ratio: 16/9;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ }
+
+ &.nMany {
+ grid-template-columns: 1fr 1fr;
+
+ > .media {
+ aspect-ratio: 16/9;
+ }
+ }
+}
+
+.media {
+ overflow: hidden; // clipにするとバグる
+ border-radius: 8px;
+ position: relative;
+
+ >.mediaInner {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.banner {
+ position: relative;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMediaVideo.vue b/packages/frontend-embed/src/components/EmMediaVideo.vue
new file mode 100644
index 0000000000..ce751f9acd
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMediaVideo.vue
@@ -0,0 +1,64 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<a :href="href" target="_blank" :class="$style.root">
+ <img v-if="!video.isSensitive && video.thumbnailUrl" :class="$style.thumbnail" :src="video.thumbnailUrl">
+ <div :class="$style.videoOverlayPlayButton"><i class="ti ti-player-play-filled"></i></div>
+</a>
+</template>
+
+<script setup lang="ts">
+import * as Misskey from 'misskey-js';
+
+defineProps<{
+ video: Misskey.entities.DriveFile;
+ href: string;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 16 / 9;
+ padding: var(--margin);
+ border: 1px solid var(--divider);
+ border-radius: var(--radius);
+ background-color: #000;
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+
+.thumbnail {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.videoOverlayPlayButton {
+ background: var(--accent);
+ color: #fff;
+ padding: 1rem;
+ border-radius: 99rem;
+
+ font-size: 1rem;
+ line-height: 1rem;
+
+ &:focus-visible {
+ outline: none;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue
new file mode 100644
index 0000000000..5eadf828c7
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMention.vue
@@ -0,0 +1,46 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkA v-user-preview="canonical" :class="[$style.root]" :to="url" :style="{ background: bgCss }">
+ <span>
+ <span>@{{ username }}</span>
+ <span v-if="(host != localHost)" :class="$style.host">@{{ toUnicode(host) }}</span>
+ </span>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { toUnicode } from 'punycode';
+import { } from 'vue';
+import tinycolor from 'tinycolor2';
+import { host as localHost } from '@/config.js';
+
+const props = defineProps<{
+ username: string;
+ host: string;
+}>();
+
+const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
+
+const url = `/${canonical}`;
+
+const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--mention'));
+bg.setAlpha(0.1);
+const bgCss = bg.toRgbString();
+</script>
+
+<style lang="scss" module>
+.root {
+ display: inline-block;
+ padding: 4px 8px 4px 4px;
+ border-radius: 999px;
+ color: var(--mention);
+}
+
+.host {
+ opacity: 0.5;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts
new file mode 100644
index 0000000000..7543d3cd54
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmMfm.ts
@@ -0,0 +1,461 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { VNode, h, SetupContext, provide } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import EmUrl from '@/components/EmUrl.vue';
+import EmTime from '@/components/EmTime.vue';
+import EmLink from '@/components/EmLink.vue';
+import EmMention from '@/components/EmMention.vue';
+import EmEmoji from '@/components/EmEmoji.vue';
+import EmCustomEmoji from '@/components/EmCustomEmoji.vue';
+import EmA from '@/components/EmA.vue';
+import { host } from '@/config.js';
+
+function safeParseFloat(str: unknown): number | null {
+ if (typeof str !== 'string' || str === '') return null;
+ const num = parseFloat(str);
+ if (isNaN(num)) return null;
+ return num;
+}
+
+const QUOTE_STYLE = `
+display: block;
+margin: 8px;
+padding: 6px 0 6px 12px;
+color: var(--fg);
+border-left: solid 3px var(--fg);
+opacity: 0.7;
+`.split('\n').join(' ');
+
+type MfmProps = {
+ text: string;
+ plain?: boolean;
+ nowrap?: boolean;
+ author?: Misskey.entities.UserLite;
+ isNote?: boolean;
+ emojiUrls?: Record<string, string>;
+ rootScale?: number;
+ nyaize?: boolean | 'respect';
+ parsedNodes?: mfm.MfmNode[] | null;
+ enableEmojiMenu?: boolean;
+ enableEmojiMenuReaction?: boolean;
+ linkNavigationBehavior?: string;
+};
+
+type MfmEvents = {
+ clickEv(id: string): void;
+};
+
+// eslint-disable-next-line import/no-default-export
+export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) {
+ provide('linkNavigationBehavior', props.linkNavigationBehavior);
+
+ const isNote = props.isNote ?? true;
+ const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (props.text == null || props.text === '') return;
+
+ const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text);
+
+ const validTime = (t: string | boolean | null | undefined) => {
+ if (t == null) return null;
+ if (typeof t === 'boolean') return null;
+ return t.match(/^\-?[0-9.]+s$/) ? t : null;
+ };
+
+ const validColor = (c: unknown): string | null => {
+ if (typeof c !== 'string') return null;
+ return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
+ };
+
+ const useAnim = true;
+
+ /**
+ * Gen Vue Elements from MFM AST
+ * @param ast MFM AST
+ * @param scale How times large the text is
+ * @param disableNyaize Whether nyaize is disabled or not
+ */
+ const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => {
+ switch (token.type) {
+ case 'text': {
+ let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+ if (!disableNyaize && shouldNyaize) {
+ text = Misskey.nyaize(text);
+ }
+
+ if (!props.plain) {
+ const res: (VNode | string)[] = [];
+ for (const t of text.split('\n')) {
+ res.push(h('br'));
+ res.push(t);
+ }
+ res.shift();
+ return res;
+ } else {
+ return [text.replace(/\n/g, ' ')];
+ }
+ }
+
+ case 'bold': {
+ return [h('b', genEl(token.children, scale))];
+ }
+
+ case 'strike': {
+ return [h('del', genEl(token.children, scale))];
+ }
+
+ case 'italic': {
+ return h('i', {
+ style: 'font-style: oblique;',
+ }, genEl(token.children, scale));
+ }
+
+ case 'fn': {
+ // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
+ let style: string | undefined;
+ switch (token.props.name) {
+ case 'tada': {
+ const speed = validTime(token.props.args.speed) ?? '1s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
+ break;
+ }
+ case 'jelly': {
+ const speed = validTime(token.props.args.speed) ?? '1s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : '');
+ break;
+ }
+ case 'twitch': {
+ const speed = validTime(token.props.args.speed) ?? '0.5s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : '';
+ break;
+ }
+ case 'shake': {
+ const speed = validTime(token.props.args.speed) ?? '0.5s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : '';
+ break;
+ }
+ case 'spin': {
+ const direction =
+ token.props.args.left ? 'reverse' :
+ token.props.args.alternate ? 'alternate' :
+ 'normal';
+ const anime =
+ token.props.args.x ? 'mfm-spinX' :
+ token.props.args.y ? 'mfm-spinY' :
+ 'mfm-spin';
+ const speed = validTime(token.props.args.speed) ?? '1.5s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : '';
+ break;
+ }
+ case 'jump': {
+ const speed = validTime(token.props.args.speed) ?? '0.75s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : '';
+ break;
+ }
+ case 'bounce': {
+ const speed = validTime(token.props.args.speed) ?? '0.75s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : '';
+ break;
+ }
+ case 'flip': {
+ const transform =
+ (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
+ token.props.args.v ? 'scaleY(-1)' :
+ 'scaleX(-1)';
+ style = `transform: ${transform};`;
+ break;
+ }
+ case 'x2': {
+ return h('span', {
+ class: 'mfm-x2',
+ }, genEl(token.children, scale * 2));
+ }
+ case 'x3': {
+ return h('span', {
+ class: 'mfm-x3',
+ }, genEl(token.children, scale * 3));
+ }
+ case 'x4': {
+ return h('span', {
+ class: 'mfm-x4',
+ }, genEl(token.children, scale * 4));
+ }
+ case 'font': {
+ const family =
+ token.props.args.serif ? 'serif' :
+ token.props.args.monospace ? 'monospace' :
+ token.props.args.cursive ? 'cursive' :
+ token.props.args.fantasy ? 'fantasy' :
+ token.props.args.emoji ? 'emoji' :
+ token.props.args.math ? 'math' :
+ null;
+ if (family) style = `font-family: ${family};`;
+ break;
+ }
+ case 'blur': {
+ return h('span', {
+ class: '_mfm_blur_',
+ }, genEl(token.children, scale));
+ }
+ case 'rainbow': {
+ if (!useAnim) {
+ return h('span', {
+ class: '_mfm_rainbow_fallback_',
+ }, genEl(token.children, scale));
+ }
+ const speed = validTime(token.props.args.speed) ?? '1s';
+ const delay = validTime(token.props.args.delay) ?? '0s';
+ style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`;
+ break;
+ }
+ case 'sparkle': {
+ return genEl(token.children, scale);
+ }
+ case 'rotate': {
+ const degrees = safeParseFloat(token.props.args.deg) ?? 90;
+ style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
+ break;
+ }
+ case 'position': {
+ const x = safeParseFloat(token.props.args.x) ?? 0;
+ const y = safeParseFloat(token.props.args.y) ?? 0;
+ style = `transform: translateX(${x}em) translateY(${y}em);`;
+ break;
+ }
+ case 'scale': {
+ const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5);
+ const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5);
+ style = `transform: scale(${x}, ${y});`;
+ scale = scale * Math.max(x, y);
+ break;
+ }
+ case 'fg': {
+ let color = validColor(token.props.args.color);
+ color = color ?? 'f00';
+ style = `color: #${color}; overflow-wrap: anywhere;`;
+ break;
+ }
+ case 'bg': {
+ let color = validColor(token.props.args.color);
+ color = color ?? 'f00';
+ style = `background-color: #${color}; overflow-wrap: anywhere;`;
+ break;
+ }
+ case 'border': {
+ let color = validColor(token.props.args.color);
+ color = color ? `#${color}` : 'var(--accent)';
+ let b_style = token.props.args.style;
+ if (
+ typeof b_style !== 'string' ||
+ !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset']
+ .includes(b_style)
+ ) b_style = 'solid';
+ const width = safeParseFloat(token.props.args.width) ?? 1;
+ const radius = safeParseFloat(token.props.args.radius) ?? 0;
+ style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`;
+ break;
+ }
+ case 'ruby': {
+ if (token.children.length === 1) {
+ const child = token.children[0];
+ let text = child.type === 'text' ? child.props.text : '';
+ if (!disableNyaize && shouldNyaize) {
+ text = Misskey.nyaize(text);
+ }
+ return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
+ } else {
+ const rt = token.children.at(-1)!;
+ let text = rt.type === 'text' ? rt.props.text : '';
+ if (!disableNyaize && shouldNyaize) {
+ text = Misskey.nyaize(text);
+ }
+ return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
+ }
+ }
+ case 'unixtime': {
+ const child = token.children[0];
+ const unixtime = parseInt(child.type === 'text' ? child.props.text : '');
+ return h('span', {
+ style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;',
+ }, [
+ h('i', {
+ class: 'ti ti-clock',
+ style: 'margin-right: 0.25em;',
+ }),
+ h(EmTime, {
+ key: Math.random(),
+ time: unixtime * 1000,
+ mode: 'detail',
+ }),
+ ]);
+ }
+ case 'clickable': {
+ return h('span', { onClick(ev: MouseEvent): void {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : '';
+ emit('clickEv', clickEv);
+ } }, genEl(token.children, scale));
+ }
+ }
+ if (style === undefined) {
+ return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
+ } else {
+ return h('span', {
+ style: 'display: inline-block; ' + style,
+ }, genEl(token.children, scale));
+ }
+ }
+
+ case 'small': {
+ return [h('small', {
+ style: 'opacity: 0.7;',
+ }, genEl(token.children, scale))];
+ }
+
+ case 'center': {
+ return [h('div', {
+ style: 'text-align:center;',
+ }, genEl(token.children, scale))];
+ }
+
+ case 'url': {
+ return [h(EmUrl, {
+ key: Math.random(),
+ url: token.props.url,
+ rel: 'nofollow noopener',
+ })];
+ }
+
+ case 'link': {
+ return [h(EmLink, {
+ key: Math.random(),
+ url: token.props.url,
+ rel: 'nofollow noopener',
+ }, genEl(token.children, scale, true))];
+ }
+
+ case 'mention': {
+ return [h(EmMention, {
+ key: Math.random(),
+ host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host,
+ username: token.props.username,
+ })];
+ }
+
+ case 'hashtag': {
+ return [h(EmA, {
+ key: Math.random(),
+ to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
+ style: 'color:var(--hashtag);',
+ }, `#${token.props.hashtag}`)];
+ }
+
+ case 'blockCode': {
+ return [h('code', {
+ key: Math.random(),
+ lang: token.props.lang ?? undefined,
+ }, token.props.code)];
+ }
+
+ case 'inlineCode': {
+ return [h('code', {
+ key: Math.random(),
+ }, token.props.code)];
+ }
+
+ case 'quote': {
+ if (!props.nowrap) {
+ return [h('div', {
+ style: QUOTE_STYLE,
+ }, genEl(token.children, scale, true))];
+ } else {
+ return [h('span', {
+ style: QUOTE_STYLE,
+ }, genEl(token.children, scale, true))];
+ }
+ }
+
+ case 'emojiCode': {
+ if (props.author?.host == null) {
+ return [h(EmCustomEmoji, {
+ key: Math.random(),
+ name: token.props.name,
+ normal: props.plain,
+ host: null,
+ useOriginalSize: scale >= 2.5,
+ menu: props.enableEmojiMenu,
+ menuReaction: props.enableEmojiMenuReaction,
+ fallbackToImage: false,
+ })];
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) {
+ return [h('span', `:${token.props.name}:`)];
+ } else {
+ return [h(EmCustomEmoji, {
+ key: Math.random(),
+ name: token.props.name,
+ url: props.emojiUrls && props.emojiUrls[token.props.name],
+ normal: props.plain,
+ host: props.author.host,
+ useOriginalSize: scale >= 2.5,
+ })];
+ }
+ }
+ }
+
+ case 'unicodeEmoji': {
+ return [h(EmEmoji, {
+ key: Math.random(),
+ emoji: token.props.emoji,
+ menu: props.enableEmojiMenu,
+ menuReaction: props.enableEmojiMenuReaction,
+ })];
+ }
+
+ case 'mathInline': {
+ return [h('code', token.props.formula)];
+ }
+
+ case 'mathBlock': {
+ return [h('code', token.props.formula)];
+ }
+
+ case 'search': {
+ return [h('div', {
+ key: Math.random(),
+ }, token.props.query)];
+ }
+
+ case 'plain': {
+ return [h('span', genEl(token.children, scale, true))];
+ }
+
+ default: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ console.error('unrecognized ast type:', (token as any).type);
+
+ return [];
+ }
+ }
+ }).flat(Infinity) as (VNode | string)[];
+
+ return h('span', {
+ // https://codeday.me/jp/qa/20190424/690106.html
+ style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;',
+ }, genEl(rootAst, props.rootScale ?? 1));
+}
diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue
new file mode 100644
index 0000000000..7c4d591066
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNote.vue
@@ -0,0 +1,609 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ v-show="!isDeleted"
+ ref="rootEl"
+ :class="[$style.root]"
+ :tabindex="isDeleted ? '-1' : '0'"
+>
+ <EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
+ <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
+ <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>-->
+ <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>-->
+ <div v-if="isRenote" :class="$style.renote">
+ <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
+ <EmAvatar :class="$style.renoteAvatar" :user="note.user" link/>
+ <i class="ti ti-repeat" style="margin-right: 4px;"></i>
+ <I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText">
+ <template #user>
+ <EmA v-user-preview="true ? undefined : note.userId" :class="$style.renoteUserName" :to="userPage(note.user)">
+ <EmUserName :user="note.user"/>
+ </EmA>
+ </template>
+ </I18n>
+ <div :class="$style.renoteInfo">
+ <button ref="renoteTime" :class="$style.renoteTime" class="_button">
+ <i class="ti ti-dots" :class="$style.renoteMenu"></i>
+ <EmTime :time="note.createdAt"/>
+ </button>
+ <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
+ <i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
+ <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+ </span>
+ <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
+ <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
+ </div>
+ </div>
+ <article :class="$style.article">
+ <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div>
+ <EmAvatar :class="$style.avatar" :user="appearNote.user" link/>
+ <div :class="$style.main">
+ <EmNoteHeader :note="appearNote" :mini="true"/>
+ <div style="container-type: inline-size;">
+ <p v-if="appearNote.cw != null" :class="$style.cw">
+ <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
+ <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+ </p>
+ <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
+ <div :class="$style.text">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+ <EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
+ <EmMfm
+ v-if="appearNote.text"
+ :parsedNodes="parsed"
+ :text="appearNote.text"
+ :author="appearNote.user"
+ :nyaize="'respect'"
+ :emojiUrls="appearNote.emojis"
+ :enableEmojiMenu="!true"
+ :enableEmojiMenuReaction="true"
+ />
+ </div>
+ <div v-if="appearNote.files && appearNote.files.length > 0">
+ <EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
+ </div>
+ <EmPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
+ <div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
+ <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
+ <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
+ </button>
+ <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
+ <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
+ </button>
+ </div>
+ <EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA>
+ </div>
+ <EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16">
+ <template #more>
+ <EmA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA>
+ </template>
+ </EmReactionsViewer>
+ <footer :class="$style.footer">
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+ <i class="ti ti-arrow-back-up"></i>
+ </a>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+ <i class="ti ti-repeat"></i>
+ </a>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
+ <i v-else class="ti ti-plus"></i>
+ </a>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button">
+ <i class="ti ti-dots"></i>
+ </a>
+ </footer>
+ </div>
+ </article>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import I18n from '@/components/I18n.vue';
+import EmNoteSub from '@/components/EmNoteSub.vue';
+import EmNoteHeader from '@/components/EmNoteHeader.vue';
+import EmNoteSimple from '@/components/EmNoteSimple.vue';
+import EmReactionsViewer from '@/components/EmReactionsViewer.vue';
+import EmMediaList from '@/components/EmMediaList.vue';
+import EmPoll from '@/components/EmPoll.vue';
+import EmMfm from '@/components/EmMfm.js';
+import EmA from '@/components/EmA.vue';
+import EmAvatar from '@/components/EmAvatar.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import EmTime from '@/components/EmTime.vue';
+import { userPage } from '@/utils.js';
+import { i18n } from '@/i18n.js';
+import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
+import { url } from '@/config.js';
+
+function getAppearNote(note: Misskey.entities.Note) {
+ return Misskey.note.isPureRenote(note) ? note.renote : note;
+}
+
+const props = withDefaults(defineProps<{
+ note: Misskey.entities.Note;
+ pinned?: boolean;
+}>(), {
+});
+
+const emit = defineEmits<{
+ (ev: 'reaction', emoji: string): void;
+ (ev: 'removeReaction', emoji: string): void;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+const note = ref((props.note));
+
+const isRenote = Misskey.note.isPureRenote(note.value);
+
+const rootEl = shallowRef<HTMLElement>();
+const renoteTime = shallowRef<HTMLElement>();
+const appearNote = computed(() => getAppearNote(note.value));
+const showContent = ref(false);
+const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
+const isLong = shouldCollapsed(appearNote.value, []);
+const collapsed = ref(appearNote.value.cw == null && isLong);
+const isDeleted = ref(false);
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ font-size: 1.05em;
+ overflow: clip;
+ contain: content;
+
+ // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+ // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+ // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+ // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+ // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
+ //content-visibility: auto;
+ //contain-intrinsic-size: 0 128px;
+
+ &:focus-visible {
+ outline: none;
+
+ &::after {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ z-index: 10;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: calc(100% - 8px);
+ height: calc(100% - 8px);
+ border: dashed 2px var(--focus);
+ border-radius: var(--radius);
+ box-sizing: border-box;
+ }
+ }
+
+ .footer {
+ position: relative;
+ z-index: 1;
+ }
+
+ &:hover > .article > .main > .footer > .footerButton {
+ opacity: 1;
+ }
+
+ &.showActionsOnlyHover {
+ .footer {
+ visibility: hidden;
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ padding: 0 4px;
+ margin-bottom: 0 !important;
+ background: var(--popup);
+ border-radius: 8px;
+ box-shadow: 0px 4px 32px var(--shadow);
+ }
+
+ .footerButton {
+ font-size: 90%;
+
+ &:not(:last-child) {
+ margin-right: 0;
+ }
+ }
+ }
+
+ &.showActionsOnlyHover:hover {
+ .footer {
+ visibility: visible;
+ }
+ }
+}
+
+.tip {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 24px;
+ font-size: 90%;
+ white-space: pre;
+ color: #d28a3f;
+}
+
+.tip + .article {
+ padding-top: 8px;
+}
+
+.replyTo {
+ opacity: 0.7;
+ padding-bottom: 0;
+}
+
+.renote {
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+
+ & + .article {
+ padding-top: 8px;
+ }
+
+ > .colorBar {
+ height: calc(100% - 6px);
+ }
+}
+
+.renoteAvatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+}
+
+.renoteText {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.renoteUserName {
+ font-weight: bold;
+}
+
+.renoteInfo {
+ margin-left: auto;
+ font-size: 0.9em;
+}
+
+.renoteTime {
+ flex-shrink: 0;
+ color: inherit;
+}
+
+.renoteMenu {
+ margin-right: 4px;
+}
+
+.collapsedRenoteTarget {
+ display: flex;
+ align-items: center;
+ line-height: 28px;
+ white-space: pre;
+ padding: 0 32px 18px;
+}
+
+.collapsedRenoteTargetAvatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+}
+
+.collapsedRenoteTargetText {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 90%;
+ opacity: 0.7;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.article {
+ position: relative;
+ display: flex;
+ padding: 28px 32px;
+}
+
+.colorBar {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 5px;
+ height: calc(100% - 16px);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
+.avatar {
+ flex-shrink: 0;
+ display: block !important;
+ margin: 0 14px 0 0;
+ width: 58px;
+ height: 58px;
+ position: sticky !important;
+ top: calc(22px + var(--stickyTop, 0px));
+ left: 0;
+}
+
+.main {
+ flex: 1;
+ min-width: 0;
+}
+
+.cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+}
+
+.showLess {
+ width: 100%;
+ margin-top: 14px;
+ position: sticky;
+ bottom: calc(var(--stickyBottom, 0px) + 14px);
+}
+
+.showLessLabel {
+ display: inline-block;
+ background: var(--popup);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.contentCollapsed {
+ position: relative;
+ max-height: 9em;
+ overflow: clip;
+}
+
+.collapsed {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ z-index: 2;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
+
+ &:hover > .collapsedLabel {
+ background: var(--panelHighlight);
+ }
+}
+
+.collapsedLabel {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.text {
+ overflow-wrap: break-word;
+}
+
+.replyIcon {
+ color: var(--accent);
+ margin-right: 0.5em;
+}
+
+.translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+}
+
+.urlPreview {
+ margin-top: 8px;
+}
+
+.poll {
+ font-size: 80%;
+}
+
+.quote {
+ padding: 8px 0;
+}
+
+.quoteNote {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ overflow: clip;
+}
+
+.channel {
+ opacity: 0.7;
+ font-size: 80%;
+}
+
+.footer {
+ margin-bottom: -14px;
+}
+
+.footerButton {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+}
+
+.footerButtonLink:hover,
+.footerButtonLink:focus,
+.footerButtonLink:active {
+ text-decoration: none;
+}
+
+.footerButtonCount {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+}
+
+@container (max-width: 580px) {
+ .root {
+ font-size: 0.95em;
+ }
+
+ .renote {
+ padding: 12px 26px 0 26px;
+ }
+
+ .article {
+ padding: 24px 26px;
+ }
+
+ .avatar {
+ width: 50px;
+ height: 50px;
+ }
+}
+
+@container (max-width: 500px) {
+ .root {
+ font-size: 0.9em;
+ }
+
+ .renote {
+ padding: 10px 22px 0 22px;
+ }
+
+ .article {
+ padding: 20px 22px;
+ }
+
+ .footer {
+ margin-bottom: -8px;
+ }
+}
+
+@container (max-width: 480px) {
+ .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ .tip {
+ padding: 8px 16px 0 16px;
+ }
+
+ .collapsedRenoteTarget {
+ padding: 0 16px 9px;
+ margin-top: 4px;
+ }
+
+ .article {
+ padding: 14px 16px;
+ }
+}
+
+@container (max-width: 450px) {
+ .avatar {
+ margin: 0 10px 0 0;
+ width: 46px;
+ height: 46px;
+ top: calc(14px + var(--stickyTop, 0px));
+ }
+}
+
+@container (max-width: 400px) {
+ .root:not(.showActionsOnlyHover) {
+ .footerButton {
+ &:not(:last-child) {
+ margin-right: 18px;
+ }
+ }
+ }
+}
+
+@container (max-width: 350px) {
+ .root:not(.showActionsOnlyHover) {
+ .footerButton {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+ }
+ }
+
+ .colorBar {
+ top: 6px;
+ left: 6px;
+ width: 4px;
+ height: calc(100% - 12px);
+ }
+}
+
+@container (max-width: 300px) {
+ .avatar {
+ width: 44px;
+ height: 44px;
+ }
+
+ .root:not(.showActionsOnlyHover) {
+ .footerButton {
+ &:not(:last-child) {
+ margin-right: 8px;
+ }
+ }
+ }
+}
+
+@container (max-width: 250px) {
+ .quoteNote {
+ padding: 12px;
+ }
+}
+
+.reactionOmitted {
+ display: inline-block;
+ margin-left: 8px;
+ opacity: .8;
+ font-size: 95%;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue
new file mode 100644
index 0000000000..74a26856c8
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue
@@ -0,0 +1,486 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ v-show="!isDeleted"
+ ref="rootEl"
+ :class="$style.root"
+>
+ <EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/>
+ <div v-if="isRenote" :class="$style.renote">
+ <EmAvatar :class="$style.renoteAvatar" :user="note.user" link/>
+ <i class="ti ti-repeat" style="margin-right: 4px;"></i>
+ <span :class="$style.renoteText">
+ <I18n :src="i18n.ts.renotedBy" tag="span">
+ <template #user>
+ <EmA :class="$style.renoteName" :to="userPage(note.user)">
+ <EmUserName :user="note.user"/>
+ </EmA>
+ </template>
+ </I18n>
+ </span>
+ <div :class="$style.renoteInfo">
+ <div class="$style.renoteTime">
+ <EmTime :time="note.createdAt"/>
+ </div>
+ <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
+ <i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
+ <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+ </span>
+ <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
+ </div>
+ </div>
+ <article :class="$style.note">
+ <header :class="$style.noteHeader">
+ <EmAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link/>
+ <div :class="$style.noteHeaderBody">
+ <div :class="$style.noteHeaderBodyUpper">
+ <div style="min-width: 0;">
+ <div class="_nowrap">
+ <EmA :class="$style.noteHeaderName" :to="userPage(appearNote.user)">
+ <EmUserName :nowrap="true" :user="appearNote.user"/>
+ </EmA>
+ <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span>
+ </div>
+ <div :class="$style.noteHeaderUsername"><EmAcct :user="appearNote.user"/></div>
+ </div>
+ <div :class="$style.noteHeaderInfo">
+ <a :href="url" :class="$style.noteHeaderInstanceIconLink" target="_blank" rel="noopener noreferrer">
+ <img :src="serverMetadata.iconUrl || '/favicon.ico'" alt="" :class="$style.noteHeaderInstanceIcon"/>
+ </a>
+ </div>
+ </div>
+ </div>
+ </header>
+ <div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
+ <p v-if="appearNote.cw != null" :class="$style.cw">
+ <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
+ <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+ </p>
+ <div v-show="appearNote.cw == null || showContent">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+ <EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
+ <EmMfm
+ v-if="appearNote.text"
+ :parsedNodes="parsed"
+ :text="appearNote.text"
+ :author="appearNote.user"
+ :nyaize="'respect'"
+ :emojiUrls="appearNote.emojis"
+ />
+ <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a>
+ <div v-if="appearNote.files && appearNote.files.length > 0">
+ <EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/>
+ </div>
+ <EmPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/>
+ <div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
+ <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false">
+ <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
+ </button>
+ <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
+ <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
+ </button>
+ </div>
+ <EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA>
+ </div>
+ <footer>
+ <div :class="$style.noteFooterInfo">
+ <span v-if="appearNote.visibility !== 'public'" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]">
+ <i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i>
+ <i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i>
+ <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+ </span>
+ <span v-if="appearNote.localOnly" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
+ <EmA :to="notePage(appearNote)">
+ <EmTime :time="appearNote.createdAt" mode="detail" colored/>
+ </EmA>
+ </div>
+ <EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :maxNumber="16" :note="appearNote">
+ <template #more>
+ <EmA :to="`/notes/${appearNote.id}`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA>
+ </template>
+ </EmReactionsViewer>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+ <i class="ti ti-arrow-back-up"></i>
+ </a>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+ <i class="ti ti-repeat"></i>
+ <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ (appearNote.renoteCount) }}</p>
+ </a>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+ <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
+ <i v-else class="ti ti-plus"></i>
+ <p v-if="(appearNote.reactionAcceptance === 'likeOnly') && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ (appearNote.reactionCount) }}</p>
+ </a>
+ <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button">
+ <i class="ti ti-dots"></i>
+ </a>
+ </footer>
+ </article>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, inject, ref } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import I18n from '@/components/I18n.vue';
+import EmMediaList from '@/components/EmMediaList.vue';
+import EmNoteSub from '@/components/EmNoteSub.vue';
+import EmNoteSimple from '@/components/EmNoteSimple.vue';
+import EmReactionsViewer from '@/components/EmReactionsViewer.vue';
+import EmPoll from '@/components/EmPoll.vue';
+import EmA from '@/components/EmA.vue';
+import EmAvatar from '@/components/EmAvatar.vue';
+import EmTime from '@/components/EmTime.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import EmAcct from '@/components/EmAcct.vue';
+import { userPage } from '@/utils.js';
+import { notePage } from '@/utils.js';
+import { i18n } from '@/i18n.js';
+import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url } from '@/config.js';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = defineProps<{
+ note: Misskey.entities.Note;
+}>();
+
+const inChannel = inject('inChannel', null);
+
+const note = ref(props.note);
+
+const isRenote = (
+ note.value.renote != null &&
+ note.value.reply == null &&
+ note.value.text == null &&
+ note.value.cw == null &&
+ note.value.fileIds && note.value.fileIds.length === 0 &&
+ note.value.poll == null
+);
+
+const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
+const showContent = ref(false);
+const isDeleted = ref(false);
+const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null;
+const isLong = shouldCollapsed(appearNote.value, []);
+const collapsed = ref(appearNote.value.cw == null && isLong);
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ overflow: clip;
+ contain: content;
+}
+
+.replyTo {
+ opacity: 0.7;
+ padding-bottom: 0;
+}
+
+.renote {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+}
+
+.renoteAvatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 6px;
+}
+
+.renoteText {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.renoteName {
+ font-weight: bold;
+}
+
+.renoteInfo {
+ margin-left: auto;
+ font-size: 0.9em;
+}
+
+.renoteTime {
+ flex-shrink: 0;
+ color: inherit;
+}
+
+.renote + .note {
+ padding-top: 8px;
+}
+
+.note {
+ padding: 24px 32px 16px;
+ font-size: 1.2em;
+
+ &:hover > .main > .footer > .button {
+ opacity: 1;
+ }
+}
+
+.noteHeader {
+ display: flex;
+ position: relative;
+ margin-bottom: 16px;
+ align-items: center;
+}
+
+.noteHeaderAvatar {
+ display: block;
+ flex-shrink: 0;
+ width: 50px;
+ height: 50px;
+}
+
+.noteHeaderBody {
+ flex: 1;
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 16px;
+ font-size: 0.95em;
+}
+
+.noteHeaderBodyUpper {
+ display: flex;
+ min-width: 0;
+}
+
+.noteHeaderName {
+ font-weight: bold;
+ line-height: 1.3;
+}
+
+.isBot {
+ display: inline-block;
+ margin: 0 0.5em;
+ padding: 4px 6px;
+ font-size: 80%;
+ line-height: 1;
+ border: solid 0.5px var(--divider);
+ border-radius: 4px;
+}
+
+.noteHeaderInfo {
+ margin-left: auto;
+ display: flex;
+ gap: 0.5em;
+ align-items: center;
+}
+
+.noteHeaderInstanceIconLink {
+ display: inline-block;
+ margin-left: 4px;
+}
+
+.noteHeaderInstanceIcon {
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+}
+
+.noteHeaderUsername {
+ margin-bottom: 2px;
+ line-height: 1.3;
+ word-wrap: anywhere;
+}
+
+.noteContent {
+ container-type: inline-size;
+ overflow-wrap: break-word;
+}
+
+.cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+}
+
+.noteReplyTarget {
+ color: var(--accent);
+ margin-right: 0.5em;
+}
+
+.rn {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+}
+
+.reactionOmitted {
+ display: inline-block;
+ margin-left: 8px;
+ opacity: .8;
+ font-size: 95%;
+}
+
+.poll {
+ font-size: 80%;
+}
+
+.quote {
+ padding: 8px 0;
+}
+
+.quoteNote {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ overflow: clip;
+}
+
+.channel {
+ opacity: 0.7;
+ font-size: 80%;
+}
+
+.showLess {
+ width: 100%;
+ margin-top: 14px;
+ position: sticky;
+ bottom: calc(var(--stickyBottom, 0px) + 14px);
+}
+
+.showLessLabel {
+ display: inline-block;
+ background: var(--popup);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.contentCollapsed {
+ position: relative;
+ max-height: 9em;
+ overflow: clip;
+}
+
+.collapsed {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ z-index: 2;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+
+ &:hover > .collapsedLabel {
+ background: var(--panelHighlight);
+ }
+}
+
+.collapsedLabel {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+
+.noteFooterInfo {
+ margin: 16px 0;
+ opacity: 0.7;
+ font-size: 0.9em;
+}
+
+.noteFooterButton {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+}
+
+.footerButtonLink:hover,
+.footerButtonLink:focus,
+.footerButtonLink:active {
+ text-decoration: none;
+}
+
+.noteFooterButtonCount {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+
+ &.reacted {
+ color: var(--accent);
+ }
+}
+
+@container (max-width: 500px) {
+ .root {
+ font-size: 0.9em;
+ }
+}
+
+@container (max-width: 450px) {
+ .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ .note {
+ padding: 16px;
+ }
+
+ .noteHeaderAvatar {
+ width: 50px;
+ height: 50px;
+ }
+}
+
+@container (max-width: 350px) {
+ .noteFooterButton {
+ &:not(:last-child) {
+ margin-right: 18px;
+ }
+ }
+}
+
+@container (max-width: 300px) {
+ .root {
+ font-size: 0.825em;
+ }
+
+ .noteHeaderAvatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ .noteFooterButton {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue
new file mode 100644
index 0000000000..e4add9501f
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteHeader.vue
@@ -0,0 +1,104 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<header :class="$style.root">
+ <EmA :class="$style.name" :to="userPage(note.user)">
+ <EmUserName :user="note.user"/>
+ </EmA>
+ <div v-if="note.user.isBot" :class="$style.isBot">bot</div>
+ <div :class="$style.username"><EmAcct :user="note.user"/></div>
+ <div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
+ <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
+ </div>
+ <div :class="$style.info">
+ <EmA :to="notePage(note)">
+ <EmTime :time="note.createdAt" colored/>
+ </EmA>
+ <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;">
+ <i v-if="note.visibility === 'home'" class="ti ti-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
+ <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
+ </span>
+ <span v-if="note.localOnly" style="margin-left: 0.5em;"><i class="ti ti-rocket-off"></i></span>
+ <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
+ </div>
+</header>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import { notePage } from '@/utils.js';
+import { userPage } from '@/utils.js';
+import EmA from '@/components/EmA.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import EmAcct from '@/components/EmAcct.vue';
+import EmTime from '@/components/EmTime.vue';
+
+defineProps<{
+ note: Misskey.entities.Note;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ display: flex;
+ align-items: baseline;
+ white-space: nowrap;
+}
+
+.name {
+ flex-shrink: 1;
+ display: block;
+ margin: 0 .5em 0 0;
+ padding: 0;
+ overflow: hidden;
+ font-size: 1em;
+ font-weight: bold;
+ text-decoration: none;
+ text-overflow: ellipsis;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.isBot {
+ flex-shrink: 0;
+ align-self: center;
+ margin: 0 .5em 0 0;
+ padding: 1px 6px;
+ font-size: 80%;
+ border: solid 0.5px var(--divider);
+ border-radius: 3px;
+}
+
+.username {
+ flex-shrink: 9999999;
+ margin: 0 .5em 0 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info {
+ flex-shrink: 0;
+ margin-left: auto;
+ font-size: 0.9em;
+}
+
+.badgeRoles {
+ margin: 0 .5em 0 0;
+}
+
+.badgeRole {
+ height: 1.3em;
+ vertical-align: -20%;
+
+ & + .badgeRole {
+ margin-left: 0.2em;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue
new file mode 100644
index 0000000000..828b6cd2e2
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteSimple.vue
@@ -0,0 +1,105 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+ <EmAvatar :class="$style.avatar" :user="note.user" link preview/>
+ <div :class="$style.main">
+ <EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
+ <div>
+ <p v-if="note.cw != null" :class="$style.cw">
+ <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+ <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+ </p>
+ <div v-show="note.cw == null || showContent">
+ <EmSubNoteContent :class="$style.text" :note="note"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import EmNoteHeader from '@/components/EmNoteHeader.vue';
+import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = defineProps<{
+ note: Misskey.entities.Note;
+}>();
+
+const showContent = ref(false);
+</script>
+
+<style lang="scss" module>
+.root {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ font-size: 0.95em;
+}
+
+.avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 10px 0 0;
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ position: sticky !important;
+ top: calc(16px + var(--stickyTop, 0px));
+ left: 0;
+}
+
+.main {
+ flex: 1;
+ min-width: 0;
+}
+
+.header {
+ margin-bottom: 2px;
+}
+
+.cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+}
+
+.text {
+ cursor: default;
+ margin: 0;
+ padding: 0;
+}
+
+@container (min-width: 250px) {
+ .avatar {
+ margin: 0 10px 0 0;
+ width: 40px;
+ height: 40px;
+ }
+}
+
+@container (min-width: 350px) {
+ .avatar {
+ margin: 0 10px 0 0;
+ width: 44px;
+ height: 44px;
+ }
+}
+
+@container (min-width: 500px) {
+ .avatar {
+ margin: 0 12px 0 0;
+ width: 48px;
+ height: 48px;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue
new file mode 100644
index 0000000000..c98b956805
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNoteSub.vue
@@ -0,0 +1,149 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.children]: depth > 1 }]">
+ <div :class="$style.main">
+ <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div>
+ <EmAvatar :class="$style.avatar" :user="note.user" link preview/>
+ <div :class="$style.body">
+ <EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
+ <div>
+ <p v-if="note.cw != null" :class="$style.cw">
+ <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
+ <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button>
+ </p>
+ <div v-show="note.cw == null || showContent">
+ <EmSubNoteContent :class="$style.text" :note="note"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <template v-if="depth < 5">
+ <EmNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1"/>
+ </template>
+ <div v-else :class="$style.more">
+ <EmA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></EmA>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmNoteHeader from '@/components/EmNoteHeader.vue';
+import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
+import { notePage } from '@/utils.js';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = withDefaults(defineProps<{
+ note: Misskey.entities.Note;
+ detail?: boolean;
+
+ // how many notes are in between this one and the note being viewed in detail
+ depth?: number;
+}>(), {
+ depth: 1,
+});
+
+const showContent = ref(false);
+const replies = ref<Misskey.entities.Note[]>([]);
+
+if (props.detail) {
+ misskeyApi('notes/children', {
+ noteId: props.note.id,
+ limit: 5,
+ }).then(res => {
+ replies.value = res;
+ });
+}
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 16px 32px;
+ font-size: 0.9em;
+ position: relative;
+
+ &.children {
+ padding: 10px 0 0 16px;
+ font-size: 1em;
+ }
+}
+
+.main {
+ display: flex;
+}
+
+.colorBar {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ width: 5px;
+ height: calc(100% - 8px);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
+.avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 8px 0 0;
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+}
+
+.body {
+ flex: 1;
+ min-width: 0;
+}
+
+.header {
+ margin-bottom: 2px;
+}
+
+.cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+}
+
+.text {
+ margin: 0;
+ padding: 0;
+}
+
+.reply, .more {
+ border-left: solid 0.5px var(--divider);
+ margin-top: 10px;
+}
+
+.more {
+ padding: 10px 0 0 16px;
+}
+
+@container (max-width: 450px) {
+ .root {
+ padding: 14px 16px;
+
+ &.children {
+ padding: 10px 0 0 8px;
+ }
+ }
+}
+
+.muted {
+ text-align: center;
+ padding: 8px !important;
+ border: 1px solid var(--divider);
+ margin: 8px 8px 0 8px;
+ border-radius: 8px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue
new file mode 100644
index 0000000000..3970d05098
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmNotes.vue
@@ -0,0 +1,48 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
+ <template #empty>
+ <div class="_fullinfo">
+ <div>{{ i18n.ts.noNotes }}</div>
+ </div>
+ </template>
+
+ <template #default="{ items: notes }">
+ <div :class="[$style.root]">
+ <EmNote v-for="note in notes" :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
+ </div>
+ </template>
+</EmPagination>
+</template>
+
+<script lang="ts" setup>
+import { shallowRef } from 'vue';
+import EmNote from '@/components/EmNote.vue';
+import EmPagination, { Paging } from '@/components/EmPagination.vue';
+import { i18n } from '@/i18n.js';
+
+const props = withDefaults(defineProps<{
+ pagination: Paging;
+ noGap?: boolean;
+ disableAutoLoad?: boolean;
+ ad?: boolean;
+}>(), {
+ ad: true,
+});
+
+const pagingComponent = shallowRef<InstanceType<typeof EmPagination>>();
+
+defineExpose({
+ pagingComponent,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ background: var(--panel);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue
new file mode 100644
index 0000000000..5d5317a912
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmPagination.vue
@@ -0,0 +1,504 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmLoading v-if="fetching"/>
+
+<EmError v-else-if="error" @retry="init()"/>
+
+<div v-else-if="empty" key="_empty_" class="empty">
+ <slot name="empty">
+ <div class="_fullinfo">
+ <div>{{ i18n.ts.nothing }}</div>
+ </div>
+ </slot>
+</div>
+
+<div v-else ref="rootEl">
+ <div v-show="pagination.reversed && more" key="_more_" class="_margin">
+ <button v-if="!moreFetching" class="_buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreAhead">
+ {{ i18n.ts.loadMore }}
+ </button>
+ <EmLoading v-else class="loading"/>
+ </div>
+ <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
+ <div v-show="!pagination.reversed && more" key="_more_" class="_margin">
+ <button v-if="!moreFetching" class="_buttonRounded _buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
+ {{ i18n.ts.loadMore }}
+ </button>
+ <EmLoading v-else class="loading"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
+import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+
+const SECOND_FETCH_LIMIT = 30;
+const TOLERANCE = 16;
+const APPEAR_MINIMUM_INTERVAL = 600;
+
+export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
+ endpoint: E;
+ limit: number;
+ params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
+
+ /**
+ * 検索APIのような、ページング不可なエンドポイントを利用する場合
+ * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
+ */
+ noPaging?: boolean;
+
+ /**
+ * items 配列の中身を逆順にする(新しい方が最後)
+ */
+ reversed?: boolean;
+
+ offsetMode?: boolean;
+
+ pageEl?: HTMLElement;
+};
+
+type MisskeyEntity = {
+ id: string;
+ createdAt: string;
+ _shouldInsertAd_?: boolean;
+ [x: string]: any;
+};
+
+type MisskeyEntityMap = Map<string, MisskeyEntity>;
+
+function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
+ return entities.map(en => [en.id, en]);
+}
+
+function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
+ return new Map([...map, ...arrayToEntries(entities)]);
+}
+
+</script>
+<script lang="ts" setup>
+import EmError from '@/components/EmError.vue';
+import EmLoading from '@/components/EmLoading.vue';
+
+const props = withDefaults(defineProps<{
+ pagination: Paging;
+ disableAutoLoad?: boolean;
+ displayLimit?: number;
+}>(), {
+ displayLimit: 20,
+});
+
+const emit = defineEmits<{
+ (ev: 'queue', count: number): void;
+ (ev: 'status', error: boolean): void;
+}>();
+
+const rootEl = shallowRef<HTMLElement>();
+
+// 遡り中かどうか
+const backed = ref(false);
+
+const scrollRemove = ref<(() => void) | null>(null);
+
+/**
+ * 表示するアイテムのソース
+ * 最新が0番目
+ */
+const items = ref<MisskeyEntityMap>(new Map());
+
+/**
+ * タブが非アクティブなどの場合に更新を貯めておく
+ * 最新が0番目
+ */
+const queue = ref<MisskeyEntityMap>(new Map());
+
+const offset = ref(0);
+
+/**
+ * 初期化中かどうか(trueならEmLoadingで全て隠す)
+ */
+const fetching = ref(true);
+
+const moreFetching = ref(false);
+const more = ref(false);
+const preventAppearFetchMore = ref(false);
+const preventAppearFetchMoreTimer = ref<number | null>(null);
+const isBackTop = ref(false);
+const empty = computed(() => items.value.size === 0);
+const error = ref(false);
+
+const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
+const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
+
+const visibility = useDocumentVisibility();
+
+let isPausingUpdate = false;
+let timerForSetPause: number | null = null;
+const BACKGROUND_PAUSE_WAIT_SEC = 10;
+
+// 先頭が表示されているかどうかを検出
+// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
+const scrollObserver = ref<IntersectionObserver>();
+
+watch([() => props.pagination.reversed, scrollableElement], () => {
+ if (scrollObserver.value) scrollObserver.value.disconnect();
+
+ scrollObserver.value = new IntersectionObserver(entries => {
+ backed.value = entries[0].isIntersecting;
+ }, {
+ root: scrollableElement.value,
+ rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
+ threshold: 0.01,
+ });
+}, { immediate: true });
+
+watch(rootEl, () => {
+ scrollObserver.value?.disconnect();
+ nextTick(() => {
+ if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
+ });
+});
+
+watch([backed, contentEl], () => {
+ if (!backed.value) {
+ if (!contentEl.value) return;
+
+ scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
+ } else {
+ if (scrollRemove.value) scrollRemove.value();
+ scrollRemove.value = null;
+ }
+});
+
+// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
+watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
+
+watch(queue, (a, b) => {
+ if (a.size === 0 && b.size === 0) return;
+ emit('queue', queue.value.size);
+}, { deep: true });
+
+watch(error, (n, o) => {
+ if (n === o) return;
+ emit('status', n);
+});
+
+async function init(): Promise<void> {
+ items.value = new Map();
+ queue.value = new Map();
+ fetching.value = true;
+ const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+ await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
+ ...params,
+ limit: props.pagination.limit ?? 10,
+ allowPartial: true,
+ }).then(res => {
+ for (let i = 0; i < res.length; i++) {
+ const item = res[i];
+ if (i === 3) item._shouldInsertAd_ = true;
+ }
+
+ if (res.length === 0 || props.pagination.noPaging) {
+ concatItems(res);
+ more.value = false;
+ } else {
+ if (props.pagination.reversed) moreFetching.value = true;
+ concatItems(res);
+ more.value = true;
+ }
+
+ offset.value = res.length;
+ error.value = false;
+ fetching.value = false;
+ }, err => {
+ error.value = true;
+ fetching.value = false;
+ });
+}
+
+const reload = (): Promise<void> => {
+ return init();
+};
+
+const fetchMore = async (): Promise<void> => {
+ if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
+ moreFetching.value = true;
+ const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+ await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
+ ...params,
+ limit: SECOND_FETCH_LIMIT,
+ ...(props.pagination.offsetMode ? {
+ offset: offset.value,
+ } : {
+ untilId: Array.from(items.value.keys()).at(-1),
+ }),
+ }).then(res => {
+ for (let i = 0; i < res.length; i++) {
+ const item = res[i];
+ if (i === 10) item._shouldInsertAd_ = true;
+ }
+
+ const reverseConcat = _res => {
+ const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
+ const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
+
+ items.value = concatMapWithArray(items.value, _res);
+
+ return nextTick(() => {
+ if (scrollableElement.value) {
+ scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
+ } else {
+ window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
+ }
+
+ return nextTick();
+ });
+ };
+
+ if (res.length === 0) {
+ if (props.pagination.reversed) {
+ reverseConcat(res).then(() => {
+ more.value = false;
+ moreFetching.value = false;
+ });
+ } else {
+ items.value = concatMapWithArray(items.value, res);
+ more.value = false;
+ moreFetching.value = false;
+ }
+ } else {
+ if (props.pagination.reversed) {
+ reverseConcat(res).then(() => {
+ more.value = true;
+ moreFetching.value = false;
+ });
+ } else {
+ items.value = concatMapWithArray(items.value, res);
+ more.value = true;
+ moreFetching.value = false;
+ }
+ }
+ offset.value += res.length;
+ }, err => {
+ moreFetching.value = false;
+ });
+};
+
+const fetchMoreAhead = async (): Promise<void> => {
+ if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
+ moreFetching.value = true;
+ const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
+ await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
+ ...params,
+ limit: SECOND_FETCH_LIMIT,
+ ...(props.pagination.offsetMode ? {
+ offset: offset.value,
+ } : {
+ sinceId: Array.from(items.value.keys()).at(-1),
+ }),
+ }).then(res => {
+ if (res.length === 0) {
+ items.value = concatMapWithArray(items.value, res);
+ more.value = false;
+ } else {
+ items.value = concatMapWithArray(items.value, res);
+ more.value = true;
+ }
+ offset.value += res.length;
+ moreFetching.value = false;
+ }, err => {
+ moreFetching.value = false;
+ });
+};
+
+/**
+ * Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
+ * APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
+ */
+const fetchMoreApperTimeoutFn = (): void => {
+ preventAppearFetchMore.value = false;
+ preventAppearFetchMoreTimer.value = null;
+};
+const fetchMoreAppearTimeout = (): void => {
+ preventAppearFetchMore.value = true;
+ preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
+};
+
+const appearFetchMore = async (): Promise<void> => {
+ if (preventAppearFetchMore.value) return;
+ await fetchMore();
+ fetchMoreAppearTimeout();
+};
+
+const appearFetchMoreAhead = async (): Promise<void> => {
+ if (preventAppearFetchMore.value) return;
+ await fetchMoreAhead();
+ fetchMoreAppearTimeout();
+};
+
+const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
+
+watch(visibility, () => {
+ if (visibility.value === 'hidden') {
+ timerForSetPause = window.setTimeout(() => {
+ isPausingUpdate = true;
+ timerForSetPause = null;
+ },
+ BACKGROUND_PAUSE_WAIT_SEC * 1000);
+ } else { // 'visible'
+ if (timerForSetPause) {
+ clearTimeout(timerForSetPause);
+ timerForSetPause = null;
+ } else {
+ isPausingUpdate = false;
+ if (isTop()) {
+ executeQueue();
+ }
+ }
+ }
+});
+
+/**
+ * 最新のものとして1つだけアイテムを追加する
+ * ストリーミングから降ってきたアイテムはこれで追加する
+ * @param item アイテム
+ */
+const prepend = (item: MisskeyEntity): void => {
+ if (items.value.size === 0) {
+ items.value.set(item.id, item);
+ fetching.value = false;
+ return;
+ }
+
+ if (isTop() && !isPausingUpdate) unshiftItems([item]);
+ else prependQueue(item);
+};
+
+/**
+ * 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
+ * @param newItems 新しいアイテムの配列
+ */
+function unshiftItems(newItems: MisskeyEntity[]) {
+ const length = newItems.length + items.value.size;
+ items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
+
+ if (length >= props.displayLimit) more.value = true;
+}
+
+/**
+ * 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
+ * @param oldItems 古いアイテムの配列
+ */
+function concatItems(oldItems: MisskeyEntity[]) {
+ const length = oldItems.length + items.value.size;
+ items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
+
+ if (length >= props.displayLimit) more.value = true;
+}
+
+function executeQueue() {
+ unshiftItems(Array.from(queue.value.values()));
+ queue.value = new Map();
+}
+
+function prependQueue(newItem: MisskeyEntity) {
+ queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
+}
+
+/*
+ * アイテムを末尾に追加する(使うの?)
+ */
+const appendItem = (item: MisskeyEntity): void => {
+ items.value.set(item.id, item);
+};
+
+const removeItem = (id: string) => {
+ items.value.delete(id);
+ queue.value.delete(id);
+};
+
+const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
+ const item = items.value.get(id);
+ if (item) items.value.set(id, replacer(item));
+
+ const queueItem = queue.value.get(id);
+ if (queueItem) queue.value.set(id, replacer(queueItem));
+};
+
+onActivated(() => {
+ isBackTop.value = false;
+});
+
+onDeactivated(() => {
+ isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
+});
+
+function toBottom() {
+ scrollToBottom(contentEl.value!);
+}
+
+onBeforeMount(() => {
+ init().then(() => {
+ if (props.pagination.reversed) {
+ nextTick(() => {
+ setTimeout(toBottom, 800);
+
+ // scrollToBottomでmoreFetchingボタンが画面外まで出るまで
+ // more = trueを遅らせる
+ setTimeout(() => {
+ moreFetching.value = false;
+ }, 2000);
+ });
+ }
+ });
+});
+
+onBeforeUnmount(() => {
+ if (timerForSetPause) {
+ clearTimeout(timerForSetPause);
+ timerForSetPause = null;
+ }
+ if (preventAppearFetchMoreTimer.value) {
+ clearTimeout(preventAppearFetchMoreTimer.value);
+ preventAppearFetchMoreTimer.value = null;
+ }
+ scrollObserver.value?.disconnect();
+});
+
+defineExpose({
+ items,
+ queue,
+ backed: backed.value,
+ more,
+ reload,
+ prepend,
+ append: appendItem,
+ removeItem,
+ updateItem,
+});
+</script>
+
+<style lang="scss" module>
+.transition_fade_enterActive,
+.transition_fade_leaveActive {
+ transition: opacity 0.125s ease;
+}
+.transition_fade_enterFrom,
+.transition_fade_leaveTo {
+ opacity: 0;
+}
+
+.more {
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue
new file mode 100644
index 0000000000..a2b1203449
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmPoll.vue
@@ -0,0 +1,82 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <ul :class="$style.choices">
+ <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice">
+ <div :class="$style.bg" :style="{ 'width': `${choice.votes / total * 100}%` }"></div>
+ <span :class="$style.fg">
+ <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
+ <EmMfm :text="choice.text" :plain="true"/>
+ <span style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
+ </span>
+ </li>
+ </ul>
+ <p :class="$style.info">
+ <span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span>
+ </p>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import { i18n } from '@/i18n.js';
+import EmMfm from '@/components/EmMfm.js';
+
+function sum(xs: number[]): number {
+ return xs.reduce((a, b) => a + b, 0);
+}
+
+const props = defineProps<{
+ noteId: string;
+ poll: NonNullable<Misskey.entities.Note['poll']>;
+}>();
+
+const total = computed(() => sum(props.poll.choices.map(x => x.votes)));
+</script>
+
+<style lang="scss" module>
+.choices {
+ display: block;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.choice {
+ display: block;
+ position: relative;
+ margin: 4px 0;
+ padding: 4px;
+ //border: solid 0.5px var(--divider);
+ background: var(--accentedBg);
+ border-radius: 4px;
+ overflow: clip;
+}
+
+.bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: var(--accent);
+ background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB));
+ transition: width 1s ease;
+}
+
+.fg {
+ position: relative;
+ display: inline-block;
+ padding: 3px 5px;
+ background: var(--panel);
+ border-radius: 3px;
+}
+
+.info {
+ color: var(--fg);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmReactionIcon.vue b/packages/frontend-embed/src/components/EmReactionIcon.vue
new file mode 100644
index 0000000000..5c38ecb0ed
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmReactionIcon.vue
@@ -0,0 +1,23 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/>
+<EmEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import EmCustomEmoji from './EmCustomEmoji.vue';
+import EmEmoji from './EmEmoji.vue';
+
+const props = defineProps<{
+ reaction: string;
+ noStyle?: boolean;
+ emojiUrl?: string;
+ withTooltip?: boolean;
+}>();
+
+</script>
diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue
new file mode 100644
index 0000000000..2e43eb8d17
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue
@@ -0,0 +1,99 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<button
+ class="_button"
+ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction }]"
+>
+ <EmReactionIcon :class="$style.limitWidth" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
+ <span :class="$style.count">{{ count }}</span>
+</button>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmReactionIcon from '@/components/EmReactionIcon.vue';
+
+const props = defineProps<{
+ reaction: string;
+ count: number;
+ isInitial: boolean;
+ note: Misskey.entities.Note;
+}>();
+</script>
+
+<style lang="scss" module>
+.root {
+ display: inline-flex;
+ height: 42px;
+ margin: 2px;
+ padding: 0 6px;
+ font-size: 1.5em;
+ border-radius: 6px;
+ align-items: center;
+ justify-content: center;
+
+ &.canToggle {
+ background: var(--buttonBg);
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.1);
+ }
+ }
+
+ &:not(.canToggle) {
+ cursor: default;
+ }
+
+ &.small {
+ height: 32px;
+ font-size: 1em;
+ border-radius: 4px;
+
+ > .count {
+ font-size: 0.9em;
+ line-height: 32px;
+ }
+ }
+
+ &.large {
+ height: 52px;
+ font-size: 2em;
+ border-radius: 8px;
+
+ > .count {
+ font-size: 0.6em;
+ line-height: 52px;
+ }
+ }
+
+ &.reacted, &.reacted:hover {
+ background: var(--accentedBg);
+ color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent) inset;
+
+ > .count {
+ color: var(--accent);
+ }
+
+ > .icon {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
+ }
+ }
+}
+
+.limitWidth {
+ max-width: 70px;
+ object-fit: contain;
+}
+
+.count {
+ font-size: 0.7em;
+ line-height: 42px;
+ margin: 0 0 0 4px;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.vue b/packages/frontend-embed/src/components/EmReactionsViewer.vue
new file mode 100644
index 0000000000..014dd1c935
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmReactionsViewer.vue
@@ -0,0 +1,104 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+ <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/>
+ <slot v-if="hasMoreReactions" name="more"></slot>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+import { inject, watch, ref } from 'vue';
+import XReaction from '@/components/EmReactionsViewer.reaction.vue';
+
+const props = withDefaults(defineProps<{
+ note: Misskey.entities.Note;
+ maxNumber?: number;
+}>(), {
+ maxNumber: Infinity,
+});
+
+const mock = inject<boolean>('mock', false);
+
+const emit = defineEmits<{
+ (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void;
+}>();
+
+const initialReactions = new Set(Object.keys(props.note.reactions));
+
+const reactions = ref<[string, number][]>([]);
+const hasMoreReactions = ref(false);
+
+if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
+ reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
+}
+
+function onMockToggleReaction(emoji: string, count: number) {
+ if (!mock) return;
+
+ const i = reactions.value.findIndex((item) => item[0] === emoji);
+ if (i < 0) return;
+
+ emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1]));
+}
+
+watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => {
+ let newReactions: [string, number][] = [];
+ hasMoreReactions.value = Object.keys(newSource).length > maxNumber;
+
+ for (let i = 0; i < reactions.value.length; i++) {
+ const reaction = reactions.value[i][0];
+ if (reaction in newSource && newSource[reaction] !== 0) {
+ reactions.value[i][1] = newSource[reaction];
+ newReactions.push(reactions.value[i]);
+ }
+ }
+
+ const newReactionsNames = newReactions.map(([x]) => x);
+ newReactions = [
+ ...newReactions,
+ ...Object.entries(newSource)
+ .sort(([, a], [, b]) => b - a)
+ .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)),
+ ];
+
+ newReactions = newReactions.slice(0, props.maxNumber);
+
+ if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) {
+ newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]);
+ }
+
+ reactions.value = newReactions;
+}, { immediate: true, deep: true });
+</script>
+
+<style lang="scss" module>
+.transition_x_move,
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
+}
+.transition_x_enterFrom,
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: scale(0.7);
+}
+.transition_x_leaveActive {
+ position: absolute;
+}
+
+.root {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin: 4px -2px 0 -2px;
+
+ &:empty {
+ display: none;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue
new file mode 100644
index 0000000000..382e39e492
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue
@@ -0,0 +1,113 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
+ <div>
+ <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
+ <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
+ <EmA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
+ <EmMfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
+ <EmA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</EmA>
+ </div>
+ <details v-if="note.files && note.files.length > 0">
+ <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary>
+ <EmMediaList :mediaList="note.files" :originalEntityUrl="`${url}/notes/${note.id}`"/>
+ </details>
+ <details v-if="note.poll">
+ <summary>{{ i18n.ts.poll }}</summary>
+ <EmPoll :noteId="note.id" :poll="note.poll"/>
+ </details>
+ <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
+ <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
+ </button>
+ <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
+ <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
+ </button>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmMediaList from '@/components/EmMediaList.vue';
+import EmPoll from '@/components/EmPoll.vue';
+import { i18n } from '@/i18n.js';
+import { url } from '@/config.js';
+import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
+import EmMfm from '@/components/EmMfm.js';
+
+const props = defineProps<{
+ note: Misskey.entities.Note;
+}>();
+
+const isLong = shouldCollapsed(props.note, []);
+
+const collapsed = ref(isLong);
+</script>
+
+<style lang="scss" module>
+.root {
+ overflow-wrap: break-word;
+
+ &.collapsed {
+ position: relative;
+ max-height: 9em;
+ overflow: clip;
+
+ > .fade {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
+
+ > .fadeLabel {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+ }
+
+ &:hover {
+ > .fadeLabel {
+ background: var(--panelHighlight);
+ }
+ }
+ }
+ }
+}
+
+.reply {
+ margin-right: 6px;
+ color: var(--accent);
+}
+
+.rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+}
+
+.showLess {
+ width: 100%;
+ margin-top: 14px;
+ position: sticky;
+ bottom: calc(var(--stickyBottom, 0px) + 14px);
+}
+
+.showLessLabel {
+ display: inline-block;
+ background: var(--popup);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue
new file mode 100644
index 0000000000..a8627e02c8
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmTime.vue
@@ -0,0 +1,107 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<time :title="absolute" :class="{ [$style.old1]: colored && (ago > 60 * 60 * 24 * 90), [$style.old2]: colored && (ago > 60 * 60 * 24 * 180) }">
+ <template v-if="invalid">{{ i18n.ts._ago.invalid }}</template>
+ <template v-else-if="mode === 'relative'">{{ relative }}</template>
+ <template v-else-if="mode === 'absolute'">{{ absolute }}</template>
+ <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
+</time>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, computed } from 'vue';
+import { i18n } from '@/i18n.js';
+import { dateTimeFormat } from '@/to-be-shared/intl-const.js';
+
+const props = withDefaults(defineProps<{
+ time: Date | string | number | null;
+ origin?: Date | null;
+ mode?: 'relative' | 'absolute' | 'detail';
+ colored?: boolean;
+}>(), {
+ origin: null,
+ mode: 'relative',
+});
+
+function getDateSafe(n: Date | string | number) {
+ try {
+ if (n instanceof Date) {
+ return n;
+ }
+ return new Date(n);
+ } catch (err) {
+ return {
+ getTime: () => NaN,
+ };
+ }
+}
+
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const _time = props.time == null ? NaN : getDateSafe(props.time).getTime();
+const invalid = Number.isNaN(_time);
+const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
+
+// eslint-disable-next-line vue/no-setup-props-reactivity-loss
+const now = ref(props.origin?.getTime() ?? Date.now());
+const ago = computed(() => (now.value - _time) / 1000/*ms*/);
+
+const relative = computed<string>(() => {
+ if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
+ if (invalid) return i18n.ts._ago.invalid;
+
+ return (
+ ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) :
+ ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) :
+ ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) :
+ ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) :
+ ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) :
+ ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) :
+ ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) :
+ ago.value >= -3 ? i18n.ts._ago.justNow :
+ ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) :
+ ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) :
+ ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) :
+ ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) :
+ ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) :
+ ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) :
+ i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() })
+ );
+});
+
+let tickId: number;
+let currentInterval: number;
+
+function tick() {
+ now.value = Date.now();
+ const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000;
+
+ if (currentInterval !== nextInterval) {
+ if (tickId) window.clearInterval(tickId);
+ currentInterval = nextInterval;
+ tickId = window.setInterval(tick, nextInterval);
+ }
+}
+
+if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) {
+ onMounted(() => {
+ tick();
+ });
+ onUnmounted(() => {
+ if (tickId) window.clearInterval(tickId);
+ });
+}
+</script>
+
+<style lang="scss" module>
+.old1 {
+ color: var(--warn);
+}
+
+.old1.old2 {
+ color: var(--error);
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmTimelineContainer.vue b/packages/frontend-embed/src/components/EmTimelineContainer.vue
new file mode 100644
index 0000000000..6c30b1102d
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmTimelineContainer.vue
@@ -0,0 +1,39 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.timelineRoot">
+ <div v-if="showHeader" :class="$style.header"><slot name="header"></slot></div>
+ <div :class="$style.body"><slot name="body"></slot></div>
+</div>
+</template>
+
+<script setup lang="ts">
+withDefaults(defineProps<{
+ showHeader?: boolean;
+}>(), {
+ showHeader: true,
+});
+</script>
+
+<style module lang="scss">
+.timelineRoot {
+ background-color: var(--panel);
+ height: 100%;
+ max-height: var(--embedMaxHeight, none);
+ display: flex;
+ flex-direction: column;
+}
+
+.header {
+ flex-shrink: 0;
+ border-bottom: 1px solid var(--divider);
+}
+
+.body {
+ flex-grow: 1;
+ overflow-y: auto;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmUrl.vue b/packages/frontend-embed/src/components/EmUrl.vue
new file mode 100644
index 0000000000..a96bfdb493
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmUrl.vue
@@ -0,0 +1,96 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<component
+ :is="self ? EmA : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target"
+ @contextmenu.stop="() => {}"
+>
+ <template v-if="!self">
+ <span :class="$style.schema">{{ schema }}//</span>
+ <span :class="$style.hostname">{{ hostname }}</span>
+ <span v-if="port != ''">:{{ port }}</span>
+ </template>
+ <template v-if="pathname === '/' && self">
+ <span :class="$style.self">{{ hostname }}</span>
+ </template>
+ <span v-if="pathname != ''" :class="$style.pathname">{{ self ? pathname.substring(1) : pathname }}</span>
+ <span :class="$style.query">{{ query }}</span>
+ <span :class="$style.hash">{{ hash }}</span>
+ <i v-if="target === '_blank'" :class="$style.icon" class="ti ti-external-link"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { toUnicode as decodePunycode } from 'punycode/';
+import EmA from './EmA.vue';
+import { url as local } from '@/config.js';
+
+function safeURIDecode(str: string): string {
+ try {
+ return decodeURIComponent(str);
+ } catch {
+ return str;
+ }
+}
+
+const props = withDefaults(defineProps<{
+ url: string;
+ rel?: string;
+ showUrlPreview?: boolean;
+}>(), {
+ showUrlPreview: true,
+});
+
+const self = props.url.startsWith(local);
+const url = new URL(props.url);
+if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
+const el = ref();
+
+const schema = url.protocol;
+const hostname = decodePunycode(url.hostname);
+const port = url.port;
+const pathname = safeURIDecode(url.pathname);
+const query = safeURIDecode(url.search);
+const hash = safeURIDecode(url.hash);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+</script>
+
+<style lang="scss" module>
+.root {
+ word-break: break-all;
+}
+
+.icon {
+ padding-left: 2px;
+ font-size: .9em;
+}
+
+.self {
+ font-weight: bold;
+}
+
+.schema {
+ opacity: 0.5;
+}
+
+.hostname {
+ font-weight: bold;
+}
+
+.pathname {
+ opacity: 0.8;
+}
+
+.query {
+ opacity: 0.5;
+}
+
+.hash {
+ font-style: italic;
+}
+</style>
diff --git a/packages/frontend-embed/src/components/EmUserName.vue b/packages/frontend-embed/src/components/EmUserName.vue
new file mode 100644
index 0000000000..c0c7c443ca
--- /dev/null
+++ b/packages/frontend-embed/src/components/EmUserName.vue
@@ -0,0 +1,21 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<EmMfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emojiUrls="user.emojis"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmMfm from './EmMfm.js';
+
+const props = withDefaults(defineProps<{
+ user: Misskey.entities.User;
+ nowrap?: boolean;
+}>(), {
+ nowrap: true,
+});
+</script>
diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue
new file mode 100644
index 0000000000..b621110ec9
--- /dev/null
+++ b/packages/frontend-embed/src/components/I18n.vue
@@ -0,0 +1,51 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<render/>
+</template>
+
+<script setup lang="ts" generic="T extends string | ParameterizedString">
+import { computed, h } from 'vue';
+import type { ParameterizedString } from '../../../../locales/index.js';
+
+const props = withDefaults(defineProps<{
+ src: T;
+ tag?: string;
+ // eslint-disable-next-line vue/require-default-prop
+ textTag?: string;
+}>(), {
+ tag: 'span',
+});
+
+const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: () => unknown } : NonNullable<unknown>>();
+
+const parsed = computed(() => {
+ let str = props.src as string;
+ const value: (string | { arg: string; })[] = [];
+ for (;;) {
+ const nextBracketOpen = str.indexOf('{');
+ const nextBracketClose = str.indexOf('}');
+
+ if (nextBracketOpen === -1) {
+ value.push(str);
+ break;
+ } else {
+ if (nextBracketOpen > 0) value.push(str.substring(0, nextBracketOpen));
+ value.push({
+ arg: str.substring(nextBracketOpen + 1, nextBracketClose),
+ });
+ }
+
+ str = str.substring(nextBracketClose + 1);
+ }
+
+ return value;
+});
+
+const render = () => {
+ return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
+};
+</script>
diff --git a/packages/frontend-embed/src/config.ts b/packages/frontend-embed/src/config.ts
new file mode 100644
index 0000000000..f9850ba461
--- /dev/null
+++ b/packages/frontend-embed/src/config.ts
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
+const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
+
+export const host = address.host;
+export const hostname = address.hostname;
+export const url = address.origin;
+export const apiUrl = location.origin + '/api';
+export const lang = localStorage.getItem('lang') ?? 'en-US';
+export const langs = _LANGS_;
+const preParseLocale = localStorage.getItem('locale');
+export const locale = preParseLocale ? JSON.parse(preParseLocale) : null;
+export const instanceName = siteName === 'Misskey' || siteName == null ? host : siteName;
+export const debug = localStorage.getItem('debug') === 'true';
diff --git a/packages/frontend-embed/src/custom-emojis.ts b/packages/frontend-embed/src/custom-emojis.ts
new file mode 100644
index 0000000000..d5b40885c1
--- /dev/null
+++ b/packages/frontend-embed/src/custom-emojis.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { shallowRef, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import { misskeyApi, misskeyApiGet } from '@/misskey-api.js';
+
+function get(key: string) {
+ const value = localStorage.getItem(key);
+ if (value === null) return null;
+ return JSON.parse(value);
+}
+
+function set(key: string, value: any) {
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
+const storageCache = await get('emojis');
+export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []);
+
+export const customEmojisMap = new Map<string, Misskey.entities.EmojiSimple>();
+watch(customEmojis, emojis => {
+ customEmojisMap.clear();
+ for (const emoji of emojis) {
+ customEmojisMap.set(emoji.name, emoji);
+ }
+}, { immediate: true });
+
+export async function fetchCustomEmojis(force = false) {
+ const now = Date.now();
+
+ let res;
+ if (force) {
+ res = await misskeyApi('emojis', {});
+ } else {
+ const lastFetchedAt = await get('lastEmojisFetchedAt');
+ if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return;
+ res = await misskeyApiGet('emojis', {});
+ }
+
+ customEmojis.value = res.emojis;
+ set('emojis', res.emojis);
+ set('lastEmojisFetchedAt', now);
+}
+
+let cachedTags;
+export function getCustomEmojiTags() {
+ if (cachedTags) return cachedTags;
+
+ const tags = new Set();
+ for (const emoji of customEmojis.value) {
+ for (const tag of emoji.aliases) {
+ tags.add(tag);
+ }
+ }
+ const res = Array.from(tags);
+ cachedTags = res;
+ return res;
+}
diff --git a/packages/frontend-embed/src/di.ts b/packages/frontend-embed/src/di.ts
new file mode 100644
index 0000000000..799bbed598
--- /dev/null
+++ b/packages/frontend-embed/src/di.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import type { InjectionKey } from 'vue';
+import * as Misskey from 'misskey-js';
+import { MediaProxy } from '@@/js/media-proxy.js';
+import type { ParsedEmbedParams } from '@@/js/embed-page.js';
+
+export const DI = {
+ serverMetadata: Symbol() as InjectionKey<Misskey.entities.MetaDetailed>,
+ embedParams: Symbol() as InjectionKey<ParsedEmbedParams>,
+ mediaProxy: Symbol() as InjectionKey<MediaProxy>,
+};
diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts
new file mode 100644
index 0000000000..17e787f9fc
--- /dev/null
+++ b/packages/frontend-embed/src/i18n.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { markRaw } from 'vue';
+import { I18n } from '@@/js/i18n.js';
+import type { Locale } from '../../../locales/index.js';
+import { locale } from '@/config.js';
+
+export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
+
+export function updateI18n(newLocale: Locale) {
+ i18n.locale = newLocale;
+}
diff --git a/packages/frontend-embed/src/index.html b/packages/frontend-embed/src/index.html
new file mode 100644
index 0000000000..47b0b0e84e
--- /dev/null
+++ b/packages/frontend-embed/src/index.html
@@ -0,0 +1,36 @@
+<!--
+ SPDX-FileCopyrightText: syuilo and misskey-project
+ SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<!--
+ 開発モードのviteはこのファイルを起点にサーバーを起動します。
+ このファイルに書かれた [t]js のリンクと (s)cssのリンクと、その依存関係にあるファイルはビルドされます
+-->
+
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8" />
+ <title>[DEV] Loading...</title>
+ <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
+ worker-src 'self';
+ script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
+ style-src 'self' 'unsafe-inline';
+ img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
+ media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
+ connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;
+ frame-src *;"
+ />
+ <meta property="og:site_name" content="[DEV BUILD] Misskey" />
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+</head>
+
+<body>
+<div id="misskey_app"></div>
+<script type="module" src="./boot.ts"></script>
+</body>
+</html>
diff --git a/packages/frontend-embed/src/misskey-api.ts b/packages/frontend-embed/src/misskey-api.ts
new file mode 100644
index 0000000000..13630590b6
--- /dev/null
+++ b/packages/frontend-embed/src/misskey-api.ts
@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { ref } from 'vue';
+import { apiUrl } from '@/config.js';
+
+export const pendingApiRequestsCount = ref(0);
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApi<
+ ResT = void,
+ E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
+ P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
+ _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
+>(
+ endpoint: E,
+ data: P = {} as any,
+ signal?: AbortSignal,
+): Promise<_ResT> {
+ if (endpoint.includes('://')) throw new Error('invalid endpoint');
+ pendingApiRequestsCount.value++;
+
+ const onFinally = () => {
+ pendingApiRequestsCount.value--;
+ };
+
+ const promise = new Promise<_ResT>((resolve, reject) => {
+ // Send request
+ window.fetch(`${apiUrl}/${endpoint}`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ credentials: 'omit',
+ cache: 'no-cache',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ signal,
+ }).then(async (res) => {
+ const body = res.status === 204 ? null : await res.json();
+
+ if (res.status === 200) {
+ resolve(body);
+ } else if (res.status === 204) {
+ resolve(undefined as _ResT); // void -> undefined
+ } else {
+ reject(body.error);
+ }
+ }).catch(reject);
+ });
+
+ promise.then(onFinally, onFinally);
+
+ return promise;
+}
+
+// Implements Misskey.api.ApiClient.request
+export function misskeyApiGet<
+ ResT = void,
+ E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
+ P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
+ _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
+>(
+ endpoint: E,
+ data: P = {} as any,
+): Promise<_ResT> {
+ pendingApiRequestsCount.value++;
+
+ const onFinally = () => {
+ pendingApiRequestsCount.value--;
+ };
+
+ const query = new URLSearchParams(data as any);
+
+ const promise = new Promise<_ResT>((resolve, reject) => {
+ // Send request
+ window.fetch(`${apiUrl}/${endpoint}?${query}`, {
+ method: 'GET',
+ credentials: 'omit',
+ cache: 'default',
+ }).then(async (res) => {
+ const body = res.status === 204 ? null : await res.json();
+
+ if (res.status === 200) {
+ resolve(body);
+ } else if (res.status === 204) {
+ resolve(undefined as _ResT); // void -> undefined
+ } else {
+ reject(body.error);
+ }
+ }).catch(reject);
+ });
+
+ promise.then(onFinally, onFinally);
+
+ return promise;
+}
diff --git a/packages/frontend-embed/src/pages/clip.vue b/packages/frontend-embed/src/pages/clip.vue
new file mode 100644
index 0000000000..6564eecd75
--- /dev/null
+++ b/packages/frontend-embed/src/pages/clip.vue
@@ -0,0 +1,140 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <MkLoading v-if="loading"/>
+ <EmTimelineContainer v-else-if="clip" :showHeader="embedParams.header">
+ <template #header>
+ <div :class="$style.clipHeader">
+ <div :class="$style.headerClipIconRoot">
+ <i class="ti ti-paperclip"></i>
+ </div>
+ <div :class="$style.headerTitle" @click="top">
+ <div class="_nowrap"><a :href="`/clips/${clip.id}`" target="_blank" rel="noopener">{{ clip.name }}</a></div>
+ <div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
+ </div>
+ <a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
+ <img
+ :class="$style.instanceIcon"
+ :src="serverMetadata.iconUrl || '/favicon.ico'"
+ />
+ </a>
+ </div>
+ </template>
+ <template #body>
+ <EmNotes
+ ref="notesEl"
+ :pagination="pagination"
+ :disableAutoLoad="!embedParams.autoload"
+ :noGap="true"
+ :ad="false"
+ />
+ </template>
+ </EmTimelineContainer>
+ <XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, shallowRef, inject } from 'vue';
+import * as Misskey from 'misskey-js';
+import { scrollToTop } from '@@/js/scroll.js';
+import type { Paging } from '@/components/EmPagination.vue';
+import EmNotes from '@/components/EmNotes.vue';
+import XNotFound from '@/pages/not-found.vue';
+import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url, instanceName } from '@/config.js';
+import { isLink } from '@/to-be-shared/is-link.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+import { DI } from '@/di.js';
+
+const props = defineProps<{
+ clipId: string;
+}>();
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+const clip = ref<Misskey.entities.Clip | null>(null);
+const pagination = computed(() => ({
+ endpoint: 'clips/notes',
+ params: {
+ clipId: props.clipId,
+ },
+} as Paging));
+const loading = ref(true);
+
+const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
+
+function top(ev: MouseEvent) {
+ const target = ev.target as HTMLElement | null;
+ if (target && isLink(target)) return;
+
+ if (notesEl.value) {
+ scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
+ }
+}
+
+misskeyApi('clips/show', {
+ clipId: props.clipId,
+}).then(res => {
+ clip.value = res;
+ loading.value = false;
+}).catch(err => {
+ console.error(err);
+ loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.clipHeader {
+ padding: 8px 16px;
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--margin);
+ overflow: hidden;
+
+ .headerClipIconRoot {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ font-size: 14px;
+ text-align: center;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ border-radius: 50%;
+ }
+
+ .headerTitle {
+ flex-grow: 1;
+ font-weight: 700;
+ line-height: 1.1;
+ min-width: 0;
+
+ .sub {
+ font-size: 0.8em;
+ font-weight: 400;
+ opacity: 0.7;
+ }
+ }
+
+ .instanceIconLink {
+ flex-shrink: 0;
+ display: block;
+ margin-left: auto;
+ height: 24px;
+ }
+
+ .instanceIcon {
+ height: 24px;
+ border-radius: 4px;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue
new file mode 100644
index 0000000000..bbb03b4e64
--- /dev/null
+++ b/packages/frontend-embed/src/pages/not-found.vue
@@ -0,0 +1,24 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <div class="_fullinfo">
+ <img :src="notFoundImageUrl" class="_ghost"/>
+ <div>{{ i18n.ts.notFoundDescription }}</div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject, computed } from 'vue';
+import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js';
+import { DI } from '@/di.js';
+import { i18n } from '@/i18n.js';
+
+const serverMetadata = inject(DI.serverMetadata)!;
+
+const notFoundImageUrl = computed(() => serverMetadata?.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
+</script>
diff --git a/packages/frontend-embed/src/pages/note.vue b/packages/frontend-embed/src/pages/note.vue
new file mode 100644
index 0000000000..86aebe072a
--- /dev/null
+++ b/packages/frontend-embed/src/pages/note.vue
@@ -0,0 +1,48 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.noteEmbedRoot">
+ <EmLoading v-if="loading"/>
+ <EmNoteDetailed v-else-if="note" :note="note"/>
+ <XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import EmNoteDetailed from '@/components/EmNoteDetailed.vue';
+import EmLoading from '@/components/EmLoading.vue';
+import XNotFound from '@/pages/not-found.vue';
+import { misskeyApi } from '@/misskey-api.js';
+
+const props = defineProps<{
+ noteId: string;
+}>();
+
+const note = ref<Misskey.entities.Note | null>(null);
+const loading = ref(true);
+
+// TODO: クライアント側でAPIを叩くのは二度手間なので予めHTMLに埋め込んでおく
+misskeyApi('notes/show', {
+ noteId: props.noteId,
+}).then(res => {
+ // リモートのノートは埋め込ませない
+ if (res.url == null && res.uri == null) {
+ note.value = res;
+ }
+ loading.value = false;
+}).catch(err => {
+ console.error(err);
+ loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.noteEmbedRoot {
+ background-color: var(--panel);
+}
+</style>
diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue
new file mode 100644
index 0000000000..d69555287a
--- /dev/null
+++ b/packages/frontend-embed/src/pages/tag.vue
@@ -0,0 +1,125 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <EmTimelineContainer v-if="tag" :showHeader="embedParams.header">
+ <template #header>
+ <div :class="$style.clipHeader">
+ <div :class="$style.headerClipIconRoot">
+ <i class="ti ti-hash"></i>
+ </div>
+ <div :class="$style.headerTitle" @click="top">
+ <div class="_nowrap"><a :href="`/tags/${tag}`" target="_blank" rel="noopener">#{{ tag }}</a></div>
+ <div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
+ </div>
+ <a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
+ <img
+ :class="$style.instanceIcon"
+ :src="serverMetadata.iconUrl || '/favicon.ico'"
+ />
+ </a>
+ </div>
+ </template>
+ <template #body>
+ <EmNotes
+ ref="notesEl"
+ :pagination="pagination"
+ :disableAutoLoad="!embedParams.autoload"
+ :noGap="true"
+ :ad="false"
+ />
+ </template>
+ </EmTimelineContainer>
+ <XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed, shallowRef, inject } from 'vue';
+import { scrollToTop } from '@@/js/scroll.js';
+import type { Paging } from '@/components/EmPagination.vue';
+import EmNotes from '@/components/EmNotes.vue';
+import XNotFound from '@/pages/not-found.vue';
+import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
+import { i18n } from '@/i18n.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url, instanceName } from '@/config.js';
+import { isLink } from '@/to-be-shared/is-link.js';
+import { DI } from '@/di.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+
+const props = defineProps<{
+ tag: string;
+}>();
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+const pagination = computed(() => ({
+ endpoint: 'notes/search-by-tag',
+ params: {
+ tag: props.tag,
+ },
+} as Paging));
+
+const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
+
+function top(ev: MouseEvent) {
+ const target = ev.target as HTMLElement | null;
+ if (target && isLink(target)) return;
+
+ if (notesEl.value) {
+ scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' });
+ }
+}
+</script>
+
+<style lang="scss" module>
+.clipHeader {
+ padding: 8px 16px;
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--margin);
+ overflow: hidden;
+
+ .headerClipIconRoot {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ font-size: 14px;
+ text-align: center;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ border-radius: 50%;
+ }
+
+ .headerTitle {
+ flex-grow: 1;
+ font-weight: 700;
+ line-height: 1.1;
+ min-width: 0;
+
+ .sub {
+ font-size: 0.8em;
+ font-weight: 400;
+ opacity: 0.7;
+ }
+ }
+
+ .instanceIconLink {
+ flex-shrink: 0;
+ display: block;
+ margin-left: auto;
+ height: 24px;
+ }
+
+ .instanceIcon {
+ height: 24px;
+ border-radius: 4px;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue
new file mode 100644
index 0000000000..d590f6e650
--- /dev/null
+++ b/packages/frontend-embed/src/pages/user-timeline.vue
@@ -0,0 +1,138 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div>
+ <EmLoading v-if="loading"/>
+ <EmTimelineContainer v-else-if="user" :showHeader="embedParams.header">
+ <template #header>
+ <div :class="$style.userHeader">
+ <a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink">
+ <EmAvatar :class="$style.avatar" :user="user"/>
+ </a>
+ <div :class="$style.headerTitle">
+ <I18n :src="i18n.ts.noteOf" tag="div" class="_nowrap">
+ <template #user>
+ <a v-if="user != null" :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer">
+ <EmUserName :user="user"/>
+ </a>
+ <span v-else>{{ i18n.ts.user }}</span>
+ </template>
+ </I18n>
+ <div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div>
+ </div>
+ <a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer">
+ <img
+ :class="$style.instanceIcon"
+ :src="serverMetadata.iconUrl || '/favicon.ico'"
+ />
+ </a>
+ </div>
+ </template>
+ <template #body>
+ <EmNotes
+ ref="notesEl"
+ :pagination="pagination"
+ :disableAutoLoad="!embedParams.autoload"
+ :noGap="true"
+ :ad="false"
+ />
+ </template>
+ </EmTimelineContainer>
+ <XNotFound v-else/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, shallowRef, inject } from 'vue';
+import * as Misskey from 'misskey-js';
+import type { Paging } from '@/components/EmPagination.vue';
+import EmNotes from '@/components/EmNotes.vue';
+import EmAvatar from '@/components/EmAvatar.vue';
+import EmLoading from '@/components/EmLoading.vue';
+import EmUserName from '@/components/EmUserName.vue';
+import I18n from '@/components/I18n.vue';
+import XNotFound from '@/pages/not-found.vue';
+import EmTimelineContainer from '@/components/EmTimelineContainer.vue';
+import { misskeyApi } from '@/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import { serverMetadata } from '@/server-metadata.js';
+import { url, instanceName } from '@/config.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+import { DI } from '@/di.js';
+
+const props = defineProps<{
+ userId: string;
+}>();
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+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);
+
+const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null);
+
+misskeyApi('users/show', {
+ userId: props.userId,
+}).then(res => {
+ user.value = res;
+ loading.value = false;
+}).catch(err => {
+ console.error(err);
+ loading.value = false;
+});
+</script>
+
+<style lang="scss" module>
+.userHeader {
+ padding: 8px 16px;
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--margin);
+ overflow: hidden;
+
+ .avatarLink {
+ display: block;
+ }
+
+ .avatar {
+ display: inline-block;
+ width: 32px;
+ height: 32px;
+ }
+
+ .headerTitle {
+ flex-grow: 1;
+ font-weight: 700;
+ line-height: 1.1;
+ min-width: 0;
+
+ .sub {
+ font-size: 0.8em;
+ font-weight: 400;
+ opacity: 0.7;
+ }
+ }
+
+ .instanceIconLink {
+ flex-shrink: 0;
+ display: block;
+ margin-left: auto;
+ height: 24px;
+ }
+
+ .instanceIcon {
+ height: 24px;
+ border-radius: 4px;
+ }
+}
+</style>
diff --git a/packages/frontend-embed/src/post-message.ts b/packages/frontend-embed/src/post-message.ts
new file mode 100644
index 0000000000..fd8eb8a5d2
--- /dev/null
+++ b/packages/frontend-embed/src/post-message.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const postMessageEventTypes = [
+ 'misskey:embed:ready',
+ 'misskey:embed:changeHeight',
+] as const;
+
+export type PostMessageEventType = typeof postMessageEventTypes[number];
+
+export interface PostMessageEventPayload extends Record<PostMessageEventType, any> {
+ 'misskey:embed:ready': undefined;
+ 'misskey:embed:changeHeight': {
+ height: number;
+ };
+}
+
+export type MiPostMessageEvent<T extends PostMessageEventType = PostMessageEventType> = {
+ type: T;
+ iframeId?: string;
+ payload?: PostMessageEventPayload[T];
+}
+
+let defaultIframeId: string | null = null;
+
+export function setIframeId(id: string): void {
+ if (defaultIframeId != null) return;
+
+ if (_DEV_) console.log('setIframeId', id);
+ defaultIframeId = id;
+}
+
+/**
+ * 親フレームにイベントを送信
+ */
+export function postMessageToParentWindow<T extends PostMessageEventType = PostMessageEventType>(type: T, payload?: PostMessageEventPayload[T], 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-embed/src/server-metadata.ts b/packages/frontend-embed/src/server-metadata.ts
new file mode 100644
index 0000000000..2bd57a0990
--- /dev/null
+++ b/packages/frontend-embed/src/server-metadata.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { misskeyApi } from '@/misskey-api.js';
+
+const providedMetaEl = document.getElementById('misskey_meta');
+
+const _serverMetadata = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null;
+
+// NOTE: devモードのときしか _serverMetadata が null になることは無い
+export const serverMetadata = _serverMetadata ?? await misskeyApi('meta', {
+ detail: true,
+});
diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss
new file mode 100644
index 0000000000..02008ddbd0
--- /dev/null
+++ b/packages/frontend-embed/src/style.scss
@@ -0,0 +1,453 @@
+@charset "utf-8";
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+:root {
+ --radius: 12px;
+ --marginFull: 14px;
+ --marginHalf: 10px;
+
+ --margin: var(--marginFull);
+}
+
+html {
+ background-color: transparent;
+ color-scheme: light dark;
+ color: var(--fg);
+ accent-color: var(--accent);
+ overflow: clip;
+ overflow-wrap: break-word;
+ font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
+ font-size: 14px;
+ line-height: 1.35;
+ text-size-adjust: 100%;
+ tab-size: 2;
+ -webkit-text-size-adjust: 100%;
+
+ &, * {
+ scrollbar-color: var(--scrollbarHandle) transparent;
+ scrollbar-width: thin;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: inherit;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: var(--scrollbarHandle);
+
+ &:hover {
+ background: var(--scrollbarHandleHover);
+ }
+
+ &:active {
+ background: var(--accent);
+ }
+ }
+ }
+}
+
+html, body {
+ height: 100%;
+ touch-action: manipulation;
+ margin: 0;
+ padding: 0;
+ scroll-behavior: smooth;
+}
+
+#misskey_app {
+ height: 100%;
+}
+
+a {
+ text-decoration: none;
+ cursor: pointer;
+ color: inherit;
+ tap-highlight-color: transparent;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-touch-callout: none;
+
+ &:focus-visible {
+ outline-offset: 2px;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &[target="_blank"] {
+ -webkit-touch-callout: default;
+ }
+}
+
+rt {
+ white-space: initial;
+}
+
+:focus-visible {
+ outline: var(--focus) solid 2px;
+ outline-offset: -2px;
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+
+.ti {
+ width: 1.28em;
+ vertical-align: -12%;
+ line-height: 1em;
+
+ &::before {
+ font-size: 128%;
+ }
+}
+
+.ti-fw {
+ display: inline-block;
+ text-align: center;
+}
+
+._nowrap {
+ white-space: pre !important;
+ word-wrap: normal !important; // https://codeday.me/jp/qa/20190424/690106.html
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+._button {
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-touch-callout: none;
+ appearance: none;
+ display: inline-block;
+ padding: 0;
+ margin: 0; // for Safari
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: inherit;
+ touch-action: manipulation;
+ tap-highlight-color: transparent;
+ -webkit-tap-highlight-color: transparent;
+ font-size: 1em;
+ font-family: inherit;
+ line-height: inherit;
+ max-width: 100%;
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+}
+
+._buttonGray {
+ @extend ._button;
+ background: var(--buttonBg);
+
+ &:not(:disabled):hover {
+ background: var(--buttonHoverBg);
+ }
+}
+
+._buttonPrimary {
+ @extend ._button;
+ color: var(--fgOnAccent);
+ background: var(--accent);
+
+ &:not(:disabled):hover {
+ background: hsl(from var(--accent) h s calc(l + 5));
+ }
+
+ &:not(:disabled):active {
+ background: hsl(from var(--accent) h s calc(l - 5));
+ }
+}
+
+._buttonGradate {
+ @extend ._buttonPrimary;
+ color: var(--fgOnAccent);
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+
+ &:not(:disabled):hover {
+ background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
+ }
+
+ &:not(:disabled):active {
+ background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
+ }
+}
+
+._buttonRounded {
+ font-size: 0.95em;
+ padding: 0.5em 1em;
+ min-width: 100px;
+ border-radius: 99rem;
+
+ &._buttonPrimary,
+ &._buttonGradate {
+ font-weight: 700;
+ }
+}
+
+._help {
+ color: var(--accent);
+ cursor: help;
+}
+
+._textButton {
+ @extend ._button;
+ color: var(--accent);
+
+ &:focus-visible {
+ outline-offset: 2px;
+ }
+
+ &:not(:disabled):hover {
+ text-decoration: underline;
+ }
+}
+
+._panel {
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: clip;
+}
+
+._margin {
+ margin: var(--margin) 0;
+}
+
+._gaps_m {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5em;
+}
+
+._gaps_s {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75em;
+}
+
+._gaps {
+ display: flex;
+ flex-direction: column;
+ gap: var(--margin);
+}
+
+._buttons {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+._buttonsCenter {
+ @extend ._buttons;
+
+ justify-content: center;
+}
+
+._borderButton {
+ @extend ._button;
+ display: block;
+ width: 100%;
+ padding: 10px;
+ box-sizing: border-box;
+ text-align: center;
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+
+ &:active {
+ border-color: var(--accent);
+ }
+}
+
+._popup {
+ background: var(--popup);
+ border-radius: var(--radius);
+ contain: content;
+}
+
+._acrylic {
+ background: var(--acrylicPanel);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+}
+
+._fullinfo {
+ padding: 64px 32px;
+ text-align: center;
+
+ > img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+ }
+}
+
+._link {
+ color: var(--link);
+}
+
+._caption {
+ font-size: 0.8em;
+ opacity: 0.7;
+}
+
+._monospace {
+ font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
+}
+
+// MFM -----------------------------
+
+._mfm_blur_ {
+ filter: blur(6px);
+ transition: filter 0.3s;
+
+ &:hover {
+ filter: blur(0px);
+ }
+}
+
+.mfm-x2 {
+ --mfm-zoom-size: 200%;
+}
+
+.mfm-x3 {
+ --mfm-zoom-size: 400%;
+}
+
+.mfm-x4 {
+ --mfm-zoom-size: 600%;
+}
+
+.mfm-x2, .mfm-x3, .mfm-x4 {
+ font-size: var(--mfm-zoom-size);
+
+ .mfm-x2, .mfm-x3, .mfm-x4 {
+ /* only half effective */
+ font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
+
+ .mfm-x2, .mfm-x3, .mfm-x4 {
+ /* disabled */
+ font-size: 100%;
+ }
+ }
+}
+
+._mfm_rainbow_fallback_ {
+ background-image: linear-gradient(to right, rgb(255, 0, 0) 0%, rgb(255, 165, 0) 17%, rgb(255, 255, 0) 33%, rgb(0, 255, 0) 50%, rgb(0, 255, 255) 67%, rgb(0, 0, 255) 83%, rgb(255, 0, 255) 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+@keyframes mfm-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+ 0% { transform: perspective(128px) rotateX(0deg); }
+ 100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+ 0% { transform: perspective(128px) rotateY(0deg); }
+ 100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+ 0% { transform: translateY(0); }
+ 25% { transform: translateY(-16px); }
+ 50% { transform: translateY(0); }
+ 75% { transform: translateY(-8px); }
+ 100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+ 0% { transform: translateY(0) scale(1, 1); }
+ 25% { transform: translateY(-16px) scale(1, 1); }
+ 50% { transform: translateY(0) scale(1, 1); }
+ 75% { transform: translateY(0) scale(1.5, 0.75); }
+ 100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+ 0% { transform: translate(7px, -2px) }
+ 5% { transform: translate(-3px, 1px) }
+ 10% { transform: translate(-7px, -1px) }
+ 15% { transform: translate(0px, -1px) }
+ 20% { transform: translate(-8px, 6px) }
+ 25% { transform: translate(-4px, -3px) }
+ 30% { transform: translate(-4px, -6px) }
+ 35% { transform: translate(-8px, -8px) }
+ 40% { transform: translate(4px, 6px) }
+ 45% { transform: translate(-3px, 1px) }
+ 50% { transform: translate(2px, -10px) }
+ 55% { transform: translate(-7px, 0px) }
+ 60% { transform: translate(-2px, 4px) }
+ 65% { transform: translate(3px, -8px) }
+ 70% { transform: translate(6px, 7px) }
+ 75% { transform: translate(-7px, -2px) }
+ 80% { transform: translate(-7px, -8px) }
+ 85% { transform: translate(9px, 3px) }
+ 90% { transform: translate(-3px, -2px) }
+ 95% { transform: translate(-10px, 2px) }
+ 100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+ 0% { transform: translate(-3px, -1px) rotate(-8deg) }
+ 5% { transform: translate(0px, -1px) rotate(-10deg) }
+ 10% { transform: translate(1px, -3px) rotate(0deg) }
+ 15% { transform: translate(1px, 1px) rotate(11deg) }
+ 20% { transform: translate(-2px, 1px) rotate(1deg) }
+ 25% { transform: translate(-1px, -2px) rotate(-2deg) }
+ 30% { transform: translate(-1px, 2px) rotate(-3deg) }
+ 35% { transform: translate(2px, 1px) rotate(6deg) }
+ 40% { transform: translate(-2px, -3px) rotate(-9deg) }
+ 45% { transform: translate(0px, -1px) rotate(-12deg) }
+ 50% { transform: translate(1px, 2px) rotate(10deg) }
+ 55% { transform: translate(0px, -3px) rotate(8deg) }
+ 60% { transform: translate(1px, -1px) rotate(8deg) }
+ 65% { transform: translate(0px, -1px) rotate(-7deg) }
+ 70% { transform: translate(-1px, -3px) rotate(6deg) }
+ 75% { transform: translate(0px, -2px) rotate(4deg) }
+ 80% { transform: translate(-2px, -1px) rotate(3deg) }
+ 85% { transform: translate(1px, -3px) rotate(-10deg) }
+ 90% { transform: translate(1px, 0px) rotate(3deg) }
+ 95% { transform: translate(-2px, 0px) rotate(-3deg) }
+ 100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+ from { transform: scale3d(1, 1, 1); }
+ 30% { transform: scale3d(1.25, 0.75, 1); }
+ 40% { transform: scale3d(0.75, 1.25, 1); }
+ 50% { transform: scale3d(1.15, 0.85, 1); }
+ 65% { transform: scale3d(0.95, 1.05, 1); }
+ 75% { transform: scale3d(1.05, 0.95, 1); }
+ to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+ 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+ 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts
new file mode 100644
index 0000000000..050d8cf63b
--- /dev/null
+++ b/packages/frontend-embed/src/theme.ts
@@ -0,0 +1,102 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import tinycolor from 'tinycolor2';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
+import type { BundledTheme } from 'shiki/themes';
+
+export type Theme = {
+ id: string;
+ name: string;
+ author: string;
+ desc?: string;
+ base?: 'dark' | 'light';
+ props: Record<string, string>;
+ codeHighlighter?: {
+ base: BundledTheme;
+ overrides?: Record<string, any>;
+ } | {
+ base: '_none_';
+ overrides: Record<string, any>;
+ };
+};
+
+let timeout: number | null = null;
+
+export function applyTheme(theme: Theme, persist = true) {
+ if (timeout) window.clearTimeout(timeout);
+
+ document.documentElement.classList.add('_themeChanging_');
+
+ timeout = window.setTimeout(() => {
+ document.documentElement.classList.remove('_themeChanging_');
+ }, 1000);
+
+ const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
+
+ // Deep copy
+ const _theme = JSON.parse(JSON.stringify(theme));
+
+ if (_theme.base) {
+ const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
+ if (base) _theme.props = Object.assign({}, base.props, _theme.props);
+ }
+
+ const props = compile(_theme);
+
+ for (const tag of document.head.children) {
+ if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
+ tag.setAttribute('content', props['htmlThemeColor']);
+ break;
+ }
+ }
+
+ for (const [k, v] of Object.entries(props)) {
+ document.documentElement.style.setProperty(`--${k}`, v.toString());
+ }
+
+ document.documentElement.style.setProperty('color-scheme', colorScheme);
+}
+
+function compile(theme: Theme): Record<string, string> {
+ function getColor(val: string): tinycolor.Instance {
+ if (val[0] === '@') { // ref (prop)
+ return getColor(theme.props[val.substring(1)]);
+ } else if (val[0] === '$') { // ref (const)
+ return getColor(theme.props[val]);
+ } else if (val[0] === ':') { // func
+ const parts = val.split('<');
+ const func = parts.shift().substring(1);
+ const arg = parseFloat(parts.shift());
+ const color = getColor(parts.join('<'));
+
+ switch (func) {
+ case 'darken': return color.darken(arg);
+ case 'lighten': return color.lighten(arg);
+ case 'alpha': return color.setAlpha(arg);
+ case 'hue': return color.spin(arg);
+ case 'saturate': return color.saturate(arg);
+ }
+ }
+
+ // other case
+ return tinycolor(val);
+ }
+
+ const props = {};
+
+ for (const [k, v] of Object.entries(theme.props)) {
+ if (k.startsWith('$')) continue; // ignore const
+
+ props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
+ }
+
+ return props;
+}
+
+function genValue(c: tinycolor.Instance): string {
+ return c.toRgbString();
+}
diff --git a/packages/frontend-embed/src/to-be-shared/collapsed.ts b/packages/frontend-embed/src/to-be-shared/collapsed.ts
new file mode 100644
index 0000000000..4ec88a3c65
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/collapsed.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+
+export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
+ const collapsed = note.cw == null && (
+ note.text != null && (
+ (note.text.includes('$[x2')) ||
+ (note.text.includes('$[x3')) ||
+ (note.text.includes('$[x4')) ||
+ (note.text.includes('$[scale')) ||
+ (note.text.split('\n').length > 9) ||
+ (note.text.length > 500) ||
+ (urls.length >= 4)
+ ) || note.files.length >= 5
+ );
+
+ return collapsed;
+}
diff --git a/packages/frontend-embed/src/to-be-shared/intl-const.ts b/packages/frontend-embed/src/to-be-shared/intl-const.ts
new file mode 100644
index 0000000000..aaa4f0a86e
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/intl-const.ts
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { lang } from '@/config.js';
+
+export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
+
+let _dateTimeFormat: Intl.DateTimeFormat;
+try {
+ _dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ });
+} catch (err) {
+ console.warn(err);
+ if (_DEV_) console.log('[Intl] Fallback to en-US');
+
+ // Fallback to en-US
+ _dateTimeFormat = new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ });
+}
+export const dateTimeFormat = _dateTimeFormat;
+
+export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
+
+export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
+
+let _numberFormat: Intl.NumberFormat;
+try {
+ _numberFormat = new Intl.NumberFormat(versatileLang);
+} catch (err) {
+ console.warn(err);
+ if (_DEV_) console.log('[Intl] Fallback to en-US');
+
+ // Fallback to en-US
+ _numberFormat = new Intl.NumberFormat('en-US');
+}
+export const numberFormat = _numberFormat;
diff --git a/packages/frontend-embed/src/to-be-shared/is-link.ts b/packages/frontend-embed/src/to-be-shared/is-link.ts
new file mode 100644
index 0000000000..946f86400e
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/is-link.ts
@@ -0,0 +1,12 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export function isLink(el: HTMLElement) {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ return false;
+}
diff --git a/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts b/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts
new file mode 100644
index 0000000000..6b3fcd9383
--- /dev/null
+++ b/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+function defaultUseWorkerNumber(prev: number, totalWorkers: number) {
+ return prev + 1;
+}
+
+export class WorkerMultiDispatch<POST = any, RETURN = any> {
+ private symbol = Symbol('WorkerMultiDispatch');
+ private workers: Worker[] = [];
+ private terminated = false;
+ private prevWorkerNumber = 0;
+ private getUseWorkerNumber = defaultUseWorkerNumber;
+ private finalizationRegistry: FinalizationRegistry<symbol>;
+
+ constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
+ this.getUseWorkerNumber = getUseWorkerNumber;
+ for (let i = 0; i < concurrency; i++) {
+ this.workers.push(workerConstructor());
+ }
+
+ this.finalizationRegistry = new FinalizationRegistry(() => {
+ this.terminate();
+ });
+ this.finalizationRegistry.register(this, this.symbol);
+
+ if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
+ }
+
+ public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: typeof defaultUseWorkerNumber = this.getUseWorkerNumber) {
+ let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
+ workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
+ if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
+ this.prevWorkerNumber = workerNumber;
+
+ // 不毛だがunionをoverloadに突っ込めない
+ // https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
+ // https://github.com/microsoft/TypeScript/issues/14107
+ if (Array.isArray(options)) {
+ this.workers[workerNumber].postMessage(message, options);
+ } else {
+ this.workers[workerNumber].postMessage(message, options);
+ }
+ return workerNumber;
+ }
+
+ public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.addEventListener('message', callback, options);
+ });
+ }
+
+ public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.removeEventListener('message', callback, options);
+ });
+ }
+
+ public terminate() {
+ this.terminated = true;
+ if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
+ this.workers.forEach(worker => {
+ worker.terminate();
+ });
+ this.workers = [];
+ this.finalizationRegistry.unregister(this);
+ }
+
+ public isTerminated() {
+ return this.terminated;
+ }
+
+ public getWorkers() {
+ return this.workers;
+ }
+
+ public getSymbol() {
+ return this.symbol;
+ }
+}
diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue
new file mode 100644
index 0000000000..3b8449dac8
--- /dev/null
+++ b/packages/frontend-embed/src/ui.vue
@@ -0,0 +1,96 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ ref="rootEl"
+ :class="[
+ $style.rootForEmbedPage,
+ {
+ [$style.rounded]: embedRounded,
+ [$style.noBorder]: embedNoBorder,
+ }
+ ]"
+ :style="maxHeight > 0 ? { maxHeight: `${maxHeight}px`, '--embedMaxHeight': `${maxHeight}px` } : {}"
+>
+ <div
+ :class="$style.routerViewContainer"
+ >
+ <EmNotePage v-if="page === 'notes'" :noteId="contentId"/>
+ <EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/>
+ <EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/>
+ <EmTagPage v-else-if="page === 'tags'" :tag="contentId"/>
+ <XNotFound v-else/>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, shallowRef, onMounted, onUnmounted, inject } from 'vue';
+import { postMessageToParentWindow } from '@/post-message.js';
+import { DI } from '@/di.js';
+import { defaultEmbedParams } from '@@/js/embed-page.js';
+import EmNotePage from '@/pages/note.vue';
+import EmUserTimelinePage from '@/pages/user-timeline.vue';
+import EmClipPage from '@/pages/clip.vue';
+import EmTagPage from '@/pages/tag.vue';
+import XNotFound from '@/pages/not-found.vue';
+
+const page = location.pathname.split('/')[2];
+const contentId = location.pathname.split('/')[3];
+console.log(page, contentId);
+
+const embedParams = inject(DI.embedParams, defaultEmbedParams);
+
+//#region Embed Style
+const embedRounded = ref(embedParams.rounded);
+const embedNoBorder = ref(!embedParams.border);
+const maxHeight = ref(embedParams.maxHeight ?? 0);
+//#endregion
+
+//#region Embed Resizer
+const rootEl = shallowRef<HTMLElement | null>(null);
+
+let previousHeight = 0;
+const resizeObserver = new ResizeObserver(async () => {
+ const height = rootEl.value!.scrollHeight + (embedNoBorder.value ? 0 : 2); // border 上下1px
+ if (Math.abs(previousHeight - height) < 1) return; // 1px未満の変化は無視
+ postMessageToParentWindow('misskey:embed:changeHeight', {
+ height: (maxHeight.value > 0 && height > maxHeight.value) ? maxHeight.value : height,
+ });
+ previousHeight = height;
+});
+onMounted(() => {
+ resizeObserver.observe(rootEl.value!);
+});
+onUnmounted(() => {
+ resizeObserver.disconnect();
+});
+//#endregion
+</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);
+ }
+
+ &.noBorder {
+ border: none;
+ }
+}
+
+.routerViewContainer {
+ container-type: inline-size;
+ max-height: var(--embedMaxHeight, none);
+}
+</style>
diff --git a/packages/frontend-embed/src/utils.ts b/packages/frontend-embed/src/utils.ts
new file mode 100644
index 0000000000..9a2fd0beef
--- /dev/null
+++ b/packages/frontend-embed/src/utils.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { url } from '@/config.js';
+
+export const acct = (user: Misskey.Acct) => {
+ return Misskey.acct.toString(user);
+};
+
+export const userName = (user: Misskey.entities.User) => {
+ return user.name || user.username;
+};
+
+export const userPage = (user: Misskey.Acct, path?: string, absolute = false) => {
+ return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
+};
+
+export const notePage = note => {
+ return `/notes/${note.id}`;
+};
diff --git a/packages/frontend-embed/src/workers/draw-blurhash.ts b/packages/frontend-embed/src/workers/draw-blurhash.ts
new file mode 100644
index 0000000000..22de6cd3a8
--- /dev/null
+++ b/packages/frontend-embed/src/workers/draw-blurhash.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { render } from 'buraha';
+
+const canvas = new OffscreenCanvas(64, 64);
+
+onmessage = (event) => {
+ // console.log(event.data);
+ if (!('id' in event.data && typeof event.data.id === 'string')) {
+ return;
+ }
+ if (!('hash' in event.data && typeof event.data.hash === 'string')) {
+ return;
+ }
+
+ render(event.data.hash, canvas);
+ const bitmap = canvas.transferToImageBitmap();
+ postMessage({ id: event.data.id, bitmap });
+};
diff --git a/packages/frontend-embed/src/workers/test-webgl2.ts b/packages/frontend-embed/src/workers/test-webgl2.ts
new file mode 100644
index 0000000000..b203ebe666
--- /dev/null
+++ b/packages/frontend-embed/src/workers/test-webgl2.ts
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+const canvas = globalThis.OffscreenCanvas && new OffscreenCanvas(1, 1);
+// 環境によってはOffscreenCanvasが存在しないため
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+const gl = canvas?.getContext('webgl2');
+if (gl) {
+ postMessage({ result: true });
+} else {
+ postMessage({ result: false });
+}
diff --git a/packages/frontend-embed/src/workers/tsconfig.json b/packages/frontend-embed/src/workers/tsconfig.json
new file mode 100644
index 0000000000..8ee8930465
--- /dev/null
+++ b/packages/frontend-embed/src/workers/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "compilerOptions": {
+ "lib": ["esnext", "webworker"],
+ }
+}
diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json
new file mode 100644
index 0000000000..3701343623
--- /dev/null
+++ b/packages/frontend-embed/tsconfig.json
@@ -0,0 +1,53 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "noEmitOnError": false,
+ "noImplicitAny": false,
+ "noImplicitReturns": true,
+ "noUnusedParameters": false,
+ "noUnusedLocals": false,
+ "noFallthroughCasesInSwitch": true,
+ "declaration": false,
+ "sourceMap": false,
+ "target": "ES2022",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "removeComments": false,
+ "noLib": false,
+ "strict": true,
+ "strictNullChecks": true,
+ "experimentalDecorators": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "isolatedModules": true,
+ "useDefineForClassFields": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"],
+ "@@/*": ["../frontend-shared/*"]
+ },
+ "typeRoots": [
+ "./@types",
+ "./node_modules/@types",
+ "./node_modules/@vue-macros",
+ "./node_modules"
+ ],
+ "types": [
+ "vite/client",
+ ],
+ "lib": [
+ "esnext",
+ "dom",
+ "dom.iterable"
+ ],
+ "jsx": "preserve"
+ },
+ "compileOnSave": false,
+ "include": [
+ "./**/*.ts",
+ "./**/*.vue"
+ ],
+ "exclude": [
+ ".storybook/**/*"
+ ]
+}
diff --git a/packages/frontend-embed/vite.config.local-dev.ts b/packages/frontend-embed/vite.config.local-dev.ts
new file mode 100644
index 0000000000..bf2f478887
--- /dev/null
+++ b/packages/frontend-embed/vite.config.local-dev.ts
@@ -0,0 +1,96 @@
+import dns from 'dns';
+import { readFile } from 'node:fs/promises';
+import type { IncomingMessage } from 'node:http';
+import { defineConfig } from 'vite';
+import type { UserConfig } from 'vite';
+import * as yaml from 'js-yaml';
+import locales from '../../locales/index.js';
+import { getConfig } from './vite.config.js';
+
+dns.setDefaultResultOrder('ipv4first');
+
+const defaultConfig = getConfig();
+
+const { port } = yaml.load(await readFile('../../.config/default.yml', 'utf-8'));
+
+const httpUrl = `http://localhost:${port}/`;
+const websocketUrl = `ws://localhost:${port}/`;
+
+// activitypubリクエストはProxyを通し、それ以外はViteの開発サーバーを返す
+function varyHandler(req: IncomingMessage) {
+ if (req.headers.accept?.includes('application/activity+json')) {
+ return null;
+ }
+ return '/index.html';
+}
+
+const devConfig: UserConfig = {
+ // 基本の設定は vite.config.js から引き継ぐ
+ ...defaultConfig,
+ root: 'src',
+ publicDir: '../assets',
+ base: '/embed',
+ server: {
+ host: 'localhost',
+ port: 5174,
+ proxy: {
+ '/api': {
+ changeOrigin: true,
+ target: httpUrl,
+ },
+ '/assets': httpUrl,
+ '/static-assets': httpUrl,
+ '/client-assets': httpUrl,
+ '/files': httpUrl,
+ '/twemoji': httpUrl,
+ '/fluent-emoji': httpUrl,
+ '/sw.js': httpUrl,
+ '/streaming': {
+ target: websocketUrl,
+ ws: true,
+ },
+ '/favicon.ico': httpUrl,
+ '/robots.txt': httpUrl,
+ '/embed.js': httpUrl,
+ '/identicon': {
+ target: httpUrl,
+ rewrite(path) {
+ return path.replace('@localhost:5173', '');
+ },
+ },
+ '/url': httpUrl,
+ '/proxy': httpUrl,
+ '/_info_card_': httpUrl,
+ '/bios': httpUrl,
+ '/cli': httpUrl,
+ '/inbox': httpUrl,
+ '/emoji/': httpUrl,
+ '/notes': {
+ target: httpUrl,
+ bypass: varyHandler,
+ },
+ '/users': {
+ target: httpUrl,
+ bypass: varyHandler,
+ },
+ '/.well-known': {
+ target: httpUrl,
+ },
+ },
+ },
+ build: {
+ ...defaultConfig.build,
+ rollupOptions: {
+ ...defaultConfig.build?.rollupOptions,
+ input: 'index.html',
+ },
+ },
+
+ define: {
+ ...defaultConfig.define,
+ _LANGS_FULL_: JSON.stringify(Object.entries(locales)),
+ },
+};
+
+export default defineConfig(({ command, mode }) => devConfig);
+
diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts
new file mode 100644
index 0000000000..64e67401c2
--- /dev/null
+++ b/packages/frontend-embed/vite.config.ts
@@ -0,0 +1,156 @@
+import path from 'path';
+import pluginVue from '@vitejs/plugin-vue';
+import { type UserConfig, defineConfig } from 'vite';
+
+import locales from '../../locales/index.js';
+import meta from '../../package.json';
+import packageInfo from './package.json' with { type: 'json' };
+import pluginJson5 from './vite.json5.js';
+
+const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
+
+/**
+ * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。
+ * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK
+ */
+const externalPackages = [
+ // shiki(コードブロックのシンタックスハイライトで使用中)はテーマ・言語の定義の容量が大きいため、それらはCDNから読み込む
+ {
+ name: 'shiki',
+ match: /^shiki\/(?<subPkg>(langs|themes))$/,
+ path(id: string, pattern: RegExp): string {
+ const match = pattern.exec(id)?.groups;
+ return match
+ ? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}`
+ : id;
+ },
+ },
+];
+
+const hash = (str: string, seed = 0): number => {
+ let h1 = 0xdeadbeef ^ seed,
+ h2 = 0x41c6ce57 ^ seed;
+ for (let i = 0, ch; i < str.length; i++) {
+ ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
+};
+
+const BASE62_DIGITS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+function toBase62(n: number): string {
+ if (n === 0) {
+ return '0';
+ }
+ let result = '';
+ while (n > 0) {
+ result = BASE62_DIGITS[n % BASE62_DIGITS.length] + result;
+ n = Math.floor(n / BASE62_DIGITS.length);
+ }
+
+ return result;
+}
+
+export function getConfig(): UserConfig {
+ return {
+ base: '/embed_vite/',
+
+ server: {
+ port: 5174,
+ },
+
+ plugins: [
+ pluginVue(),
+ pluginJson5(),
+ ],
+
+ resolve: {
+ extensions,
+ alias: {
+ '@/': __dirname + '/src/',
+ '@@/': __dirname + '/../frontend-shared/',
+ '/client-assets/': __dirname + '/assets/',
+ '/static-assets/': __dirname + '/../backend/assets/'
+ },
+ },
+
+ css: {
+ modules: {
+ generateScopedName(name, filename, _css): string {
+ const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, '');
+ if (process.env.NODE_ENV === 'production') {
+ return 'x' + toBase62(hash(id)).substring(0, 4);
+ } else {
+ return id;
+ }
+ },
+ },
+ },
+
+ define: {
+ _VERSION_: JSON.stringify(meta.version),
+ _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
+ _ENV_: JSON.stringify(process.env.NODE_ENV),
+ _DEV_: process.env.NODE_ENV !== 'production',
+ _PERF_PREFIX_: JSON.stringify('Misskey:'),
+ __VUE_OPTIONS_API__: false,
+ __VUE_PROD_DEVTOOLS__: false,
+ },
+
+ build: {
+ target: [
+ 'chrome116',
+ 'firefox116',
+ 'safari16',
+ ],
+ manifest: 'manifest.json',
+ rollupOptions: {
+ input: {
+ app: './src/boot.ts',
+ },
+ external: externalPackages.map(p => p.match),
+ output: {
+ manualChunks: {
+ vue: ['vue'],
+ },
+ chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
+ assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
+ paths(id) {
+ for (const p of externalPackages) {
+ if (p.match.test(id)) {
+ return p.path(id, p.match);
+ }
+ }
+
+ return id;
+ },
+ },
+ },
+ cssCodeSplit: true,
+ outDir: __dirname + '/../../built/_frontend_embed_vite_',
+ assetsDir: '.',
+ emptyOutDir: false,
+ sourcemap: process.env.NODE_ENV === 'development',
+ reportCompressedSize: false,
+
+ // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies
+ commonjsOptions: {
+ include: [/misskey-js/, /node_modules/],
+ },
+ },
+
+ worker: {
+ format: 'es',
+ },
+ };
+}
+
+const config = defineConfig(({ command, mode }) => getConfig());
+
+export default config;
diff --git a/packages/frontend-embed/vite.json5.ts b/packages/frontend-embed/vite.json5.ts
new file mode 100644
index 0000000000..87b67c2142
--- /dev/null
+++ b/packages/frontend-embed/vite.json5.ts
@@ -0,0 +1,48 @@
+// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json
+
+import JSON5 from 'json5';
+import { Plugin } from 'rollup';
+import { createFilter, dataToEsm } from '@rollup/pluginutils';
+import { RollupJsonOptions } from '@rollup/plugin-json';
+
+// json5 extends SyntaxError with additional fields (without subclassing)
+// https://github.com/json5/json5/blob/de344f0619bda1465a6e25c76f1c0c3dda8108d9/lib/parse.js#L1111-L1112
+interface Json5SyntaxError extends SyntaxError {
+ lineNumber: number;
+ columnNumber: number;
+}
+
+export default function json5(options: RollupJsonOptions = {}): Plugin {
+ const filter = createFilter(options.include, options.exclude);
+ const indent = 'indent' in options ? options.indent : '\t';
+
+ return {
+ name: 'json5',
+
+ // eslint-disable-next-line no-shadow
+ transform(json, id) {
+ if (id.slice(-6) !== '.json5' || !filter(id)) return null;
+
+ try {
+ const parsed = JSON5.parse(json);
+ return {
+ code: dataToEsm(parsed, {
+ preferConst: options.preferConst,
+ compact: options.compact,
+ namedExports: options.namedExports,
+ indent,
+ }),
+ map: { mappings: '' },
+ };
+ } catch (err) {
+ if (!(err instanceof SyntaxError)) {
+ throw err;
+ }
+ const message = 'Could not parse JSON5 file';
+ const { lineNumber, columnNumber } = err as Json5SyntaxError;
+ this.warn({ message, id, loc: { line: lineNumber, column: columnNumber } });
+ return null;
+ }
+ },
+ };
+}
diff --git a/packages/frontend-embed/vue-shims.d.ts b/packages/frontend-embed/vue-shims.d.ts
new file mode 100644
index 0000000000..eba994772d
--- /dev/null
+++ b/packages/frontend-embed/vue-shims.d.ts
@@ -0,0 +1,6 @@
+/* eslint-disable */
+declare module "*.vue" {
+ import { defineComponent } from "vue";
+ const component: ReturnType<typeof defineComponent>;
+ export default component;
+}