From c8f49b6ae71d5b91c7f8d051be30dcc975692ece Mon Sep 17 00:00:00 2001
From: taiy <53635909+taiyme@users.noreply.github.com>
Date: Fri, 6 Sep 2024 14:45:53 +0900
Subject: [PATCH 1/8] =?UTF-8?q?chore(ci/lint):=20ESLint=E3=81=AE=E3=82=AD?=
=?UTF-8?q?=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E3=81=8C=E4=BF=9D=E5=AD=98?=
=?UTF-8?q?=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92?=
=?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#14506)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/lint.yml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 1f13f4fa2f..222a14d28d 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -40,8 +40,6 @@ jobs:
needs: [pnpm_install]
runs-on: ubuntu-latest
continue-on-error: true
- env:
- eslint-cache-version: v1
strategy:
matrix:
workspace:
@@ -49,6 +47,9 @@ jobs:
- frontend
- sw
- misskey-js
+ env:
+ eslint-cache-version: v1
+ eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
steps:
- uses: actions/checkout@v4.1.1
with:
@@ -64,11 +65,10 @@ jobs:
- name: Restore eslint cache
uses: actions/cache@v4.0.2
with:
- path: node_modules/.cache/eslint
- key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
- restore-keys: |
- eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-
- - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content
+ path: ${{ env.eslint-cache-path }}
+ key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
+ restore-keys: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
+ - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location ${{ env.eslint-cache-path }} --cache-strategy content
typecheck:
needs: [pnpm_install]
From f7398faeac1d4deb56853f51db09eae077e535e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Fri, 6 Sep 2024 15:37:03 +0900
Subject: [PATCH 2/8] =?UTF-8?q?enhance(frontend):=20=E3=82=A2=E3=82=A4?=
=?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=87=E3=82=B3=E3=83=AC=E3=83=BC=E3=82=B7?=
=?UTF-8?q?=E3=83=A7=E3=83=B3=E7=AE=A1=E7=90=86=E7=94=BB=E9=9D=A2=E3=81=AB?=
=?UTF-8?q?=E3=83=97=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=82=92=E8=BF=BD?=
=?UTF-8?q?=E5=8A=A0=20(#14511)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* enhance(frontend): アイコンデコレーション管理画面にプレビューを追加
* Update Changelog
* tweak
---
CHANGELOG.md | 1 +
.../frontend/src/pages/avatar-decorations.vue | 93 ++++++++++++++++---
2 files changed, 81 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe61d69823..398134436b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Client
- サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
+- Enhance: アイコンデコレーション管理画面にプレビューを追加
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
### Server
diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue
index ad9ec3c4ee..b377314856 100644
--- a/packages/frontend/src/pages/avatar-decorations.vue
+++ b/packages/frontend/src/pages/avatar-decorations.vue
@@ -12,19 +12,31 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ avatarDecoration.name }}
{{ avatarDecoration.description }}
-
-
- {{ i18n.ts.name }}
-
-
- {{ i18n.ts.description }}
-
-
- {{ i18n.ts.imageUrl }}
-
-
-
+
+
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 @@
+
+
+
+
+ @{{ user.username }}
+ @{{ user.host || host }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+:{{ customEmojiName }}:
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
{{ i18n.ts.somethingHappened }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
{{ instance.name }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ i18n.ts.audio }}
+ {{ i18n.ts.file }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.sensitive }}
+ {{ i18n.ts.image }}
+ {{ i18n.ts.clickToShow }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ @{{ username }}
+ @{{ toUnicode(host) }}
+
+
+
+
+
+
+
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
;
+ 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['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 @@
+
+
+
+
+
+
{{ i18n.ts.pinnedNote }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({{ i18n.ts.private }})
+
+
+
+
+
+
+
+
+
+
+
+
{{ appearNote.channel.name }}
+
+
+
+ {{ i18n.ts.more }}
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
({{ i18n.ts.private }})
+
+
+
RN:
+
+
+
+
+
+
+
+
+
{{ appearNote.channel.name }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ bot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.continueThread }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
{{ i18n.ts.noNotes }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
{{ i18n.ts.nothing }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ -
+
+
+
+
+ ({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})
+
+
+
+
+ {{ i18n.tsx._poll.totalVotes({ n: total }) }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ ({{ i18n.ts.private }})
+ ({{ i18n.ts.deletedNote }})
+
+
+ RN: ...
+
+
+ ({{ i18n.tsx.withNFiles({ n: note.files.length }) }})
+
+
+
+ {{ i18n.ts.poll }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ {}"
+>
+
+ {{ schema }}//
+ {{ hostname }}
+ :{{ port }}
+
+
+ {{ hostname }}
+
+ {{ self ? pathname.substring(1) : pathname }}
+ {{ query }}
+ {{ hash }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
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('meta[property="instance_url"]')?.content || location.href);
+const siteName = document.querySelector('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(Array.isArray(storageCache) ? storageCache : []);
+
+export const customEmojisMap = new Map();
+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,
+ embedParams: Symbol() as InjectionKey,
+ mediaProxy: Symbol() as InjectionKey,
+};
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, _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 @@
+
+
+
+
+
+
+
+
+ [DEV] Loading...
+
+
+
+
+
+
+
+
+
+
+
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 : 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 : 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ i18n.tsx.fromX({ x: instanceName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
{{ i18n.ts.notFoundDescription }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ i18n.tsx.fromX({ x: instanceName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.ts.user }}
+
+
+
{{ i18n.tsx.fromX({ x: instanceName }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {
+ 'misskey:embed:ready': undefined;
+ 'misskey:embed:changeHeight': {
+ height: number;
+ };
+}
+
+export type MiPostMessageEvent = {
+ 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(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;
+ codeHighlighter?: {
+ base: BundledTheme;
+ overrides?: Record;
+ } | {
+ base: '_none_';
+ overrides: Record;
+ };
+};
+
+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 {
+ 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 {
+ private symbol = Symbol('WorkerMultiDispatch');
+ private workers: Worker[] = [];
+ private terminated = false;
+ private prevWorkerNumber = 0;
+ private getUseWorkerNumber = defaultUseWorkerNumber;
+ private finalizationRegistry: FinalizationRegistry;
+
+ 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) => any, options?: boolean | AddEventListenerOptions) {
+ this.workers.forEach(worker => {
+ worker.addEventListener('message', callback, options);
+ });
+ }
+
+ public removeListener(callback: (this: Worker, ev: MessageEvent) => 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 @@
+
+
+
+
+
+
+
+
+
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\/(?(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;
+ export default component;
+}
diff --git a/packages/frontend-shared/.gitignore b/packages/frontend-shared/.gitignore
new file mode 100644
index 0000000000..5f6be09d7c
--- /dev/null
+++ b/packages/frontend-shared/.gitignore
@@ -0,0 +1,2 @@
+/storybook-static
+js-built
diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js
new file mode 100644
index 0000000000..17b6da8d30
--- /dev/null
+++ b/packages/frontend-shared/build.js
@@ -0,0 +1,106 @@
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'node:path';
+import * as esbuild from 'esbuild';
+import { build } from 'esbuild';
+import { globSync } from 'glob';
+import { execa } from 'execa';
+
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
+
+const entryPoints = globSync('./js/**/**.{ts,tsx}');
+
+/** @type {import('esbuild').BuildOptions} */
+const options = {
+ entryPoints,
+ minify: process.env.NODE_ENV === 'production',
+ outdir: './js-built',
+ target: 'es2022',
+ platform: 'browser',
+ format: 'esm',
+ sourcemap: 'linked',
+};
+
+// js-built配下をすべて削除する
+fs.rmSync('./js-built', { recursive: true, force: true });
+
+if (process.argv.map(arg => arg.toLowerCase()).includes('--watch')) {
+ await watchSrc();
+} else {
+ await buildSrc();
+}
+
+async function buildSrc() {
+ console.log(`[${_package.name}] start building...`);
+
+ await build(options)
+ .then(() => {
+ console.log(`[${_package.name}] build succeeded.`);
+ })
+ .catch((err) => {
+ process.stderr.write(err.stderr);
+ process.exit(1);
+ });
+
+ if (process.env.NODE_ENV === 'production') {
+ console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`);
+ } else {
+ await buildDts();
+ }
+
+ fs.copyFileSync('./js/emojilist.json', './js-built/emojilist.json');
+
+ console.log(`[${_package.name}] finish building.`);
+}
+
+function buildDts() {
+ return execa(
+ 'tsc',
+ [
+ '--project', 'tsconfig.json',
+ '--outDir', 'js-built',
+ '--declaration', 'true',
+ '--emitDeclarationOnly', 'true',
+ ],
+ {
+ stdout: process.stdout,
+ stderr: process.stderr,
+ },
+ );
+}
+
+async function watchSrc() {
+ const plugins = [{
+ name: 'gen-dts',
+ setup(build) {
+ build.onStart(() => {
+ console.log(`[${_package.name}] detect changed...`);
+ });
+ build.onEnd(async result => {
+ if (result.errors.length > 0) {
+ console.error(`[${_package.name}] watch build failed:`, result);
+ return;
+ }
+ await buildDts();
+ });
+ },
+ }];
+
+ console.log(`[${_package.name}] start watching...`);
+
+ const context = await esbuild.context({ ...options, plugins });
+ await context.watch();
+
+ await new Promise((resolve, reject) => {
+ process.on('SIGHUP', resolve);
+ process.on('SIGINT', resolve);
+ process.on('SIGTERM', resolve);
+ process.on('uncaughtException', reject);
+ process.on('exit', resolve);
+ }).finally(async () => {
+ await context.dispose();
+ console.log(`[${_package.name}] finish watching.`);
+ });
+}
diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js
new file mode 100644
index 0000000000..a15fb29e37
--- /dev/null
+++ b/packages/frontend-shared/eslint.config.js
@@ -0,0 +1,96 @@
+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';
+
+// eslint-disable-next-line import/no-default-export
+export default [
+ ...sharedConfig,
+ {
+ files: ['**/*.vue'],
+ ...pluginMisskey.configs.typescript,
+ },
+ ...pluginVue.configs['flat/recommended'],
+ {
+ files: ['js/**/*.{ts,vue}', '**/*.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/src/const.ts b/packages/frontend-shared/js/const.ts
similarity index 98%
rename from packages/frontend/src/const.ts
rename to packages/frontend-shared/js/const.ts
index e135bc69a0..8391fb638c 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -127,7 +127,7 @@ export const MFM_PARAMS: Record = {
position: ['x=', 'y='],
fg: ['color='],
bg: ['color='],
- border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
+ border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
blur: [],
rainbow: ['speed=', 'delay='],
diff --git a/packages/frontend-shared/js/embed-page.ts b/packages/frontend-shared/js/embed-page.ts
new file mode 100644
index 0000000000..d5555a98c3
--- /dev/null
+++ b/packages/frontend-shared/js/embed-page.ts
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+//#region Embed関連の定義
+
+/** 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる) */
+const embeddableEntities = [
+ 'notes',
+ 'user-timeline',
+ 'clips',
+ 'tags',
+] as const;
+
+/** 埋め込みの対象となるエンティティ */
+export type EmbeddableEntity = typeof embeddableEntities[number];
+
+/** 内部でスクロールがあるページ */
+export const embedRouteWithScrollbar: EmbeddableEntity[] = [
+ 'clips',
+ 'tags',
+ 'user-timeline',
+];
+
+/** 埋め込みコードのパラメータ */
+export type EmbedParams = {
+ maxHeight?: number;
+ colorMode?: 'light' | 'dark';
+ rounded?: boolean;
+ border?: boolean;
+ autoload?: boolean;
+ header?: boolean;
+};
+
+/** 正規化されたパラメータ */
+export type ParsedEmbedParams = Required> & Pick;
+
+/** パラメータのデフォルトの値 */
+export const defaultEmbedParams = {
+ maxHeight: undefined,
+ colorMode: undefined,
+ rounded: true,
+ border: true,
+ autoload: false,
+ header: true,
+} as const satisfies EmbedParams;
+
+//#endregion
+
+/**
+ * パラメータを正規化する(埋め込みページ初期化用)
+ * @param searchParams URLSearchParamsもしくはクエリ文字列
+ * @returns 正規化されたパラメータ
+ */
+export function parseEmbedParams(searchParams: URLSearchParams | string): ParsedEmbedParams {
+ let _searchParams: URLSearchParams;
+ if (typeof searchParams === 'string') {
+ _searchParams = new URLSearchParams(searchParams);
+ } else if (searchParams instanceof URLSearchParams) {
+ _searchParams = searchParams;
+ } else {
+ throw new Error('searchParams must be URLSearchParams or string');
+ }
+
+ function convertBoolean(value: string | null): boolean | undefined {
+ if (value === 'true') {
+ return true;
+ } else if (value === 'false') {
+ return false;
+ }
+ return undefined;
+ }
+
+ function convertNumber(value: string | null): number | undefined {
+ if (value != null && !isNaN(Number(value))) {
+ return Number(value);
+ }
+ return undefined;
+ }
+
+ function convertColorMode(value: string | null): 'light' | 'dark' | undefined {
+ if (value != null && ['light', 'dark'].includes(value)) {
+ return value as 'light' | 'dark';
+ }
+ return undefined;
+ }
+
+ return {
+ maxHeight: convertNumber(_searchParams.get('maxHeight')) ?? defaultEmbedParams.maxHeight,
+ colorMode: convertColorMode(_searchParams.get('colorMode')) ?? defaultEmbedParams.colorMode,
+ rounded: convertBoolean(_searchParams.get('rounded')) ?? defaultEmbedParams.rounded,
+ border: convertBoolean(_searchParams.get('border')) ?? defaultEmbedParams.border,
+ autoload: convertBoolean(_searchParams.get('autoload')) ?? defaultEmbedParams.autoload,
+ header: convertBoolean(_searchParams.get('header')) ?? defaultEmbedParams.header,
+ };
+}
diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend-shared/js/emoji-base.ts
similarity index 100%
rename from packages/frontend/src/scripts/emoji-base.ts
rename to packages/frontend-shared/js/emoji-base.ts
diff --git a/packages/frontend/src/emojilist.json b/packages/frontend-shared/js/emojilist.json
similarity index 100%
rename from packages/frontend/src/emojilist.json
rename to packages/frontend-shared/js/emojilist.json
diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend-shared/js/emojilist.ts
similarity index 96%
rename from packages/frontend/src/scripts/emojilist.ts
rename to packages/frontend-shared/js/emojilist.ts
index 6565feba97..bde30a864f 100644
--- a/packages/frontend/src/scripts/emojilist.ts
+++ b/packages/frontend-shared/js/emojilist.ts
@@ -12,12 +12,12 @@ export type UnicodeEmojiDef = {
}
// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
-import _emojilist from '../emojilist.json';
+import _emojilist from './emojilist.json';
export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
name: x[1] as string,
char: x[0] as string,
- category: unicodeEmojiCategories[x[2]],
+ category: unicodeEmojiCategories[x[2] as number],
}));
const unicodeEmojisMap = new Map(
diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend-shared/js/extract-avg-color-from-blurhash.ts
similarity index 100%
rename from packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts
rename to packages/frontend-shared/js/extract-avg-color-from-blurhash.ts
diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend-shared/js/i18n.ts
similarity index 91%
rename from packages/frontend/src/scripts/i18n.ts
rename to packages/frontend-shared/js/i18n.ts
index b258a2a678..18232691fa 100644
--- a/packages/frontend/src/scripts/i18n.ts
+++ b/packages/frontend-shared/js/i18n.ts
@@ -2,7 +2,10 @@
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import type { ILocale, ParameterizedString } from '../../../../locales/index.js';
+import type { ILocale, ParameterizedString } from '../../../locales/index.js';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type TODO = any;
type FlattenKeys = keyof {
[K in keyof T as T[K] extends ILocale
@@ -32,15 +35,18 @@ type Tsx = {
export class I18n {
private tsxCache?: Tsx;
+ private devMode: boolean;
+
+ constructor(public locale: T, devMode = false) {
+ this.devMode = devMode;
- constructor(public locale: T) {
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
public get ts(): T {
- if (_DEV_) {
+ if (this.devMode) {
class Handler implements ProxyHandler {
get(target: TTarget, p: string | symbol): unknown {
const value = target[p as keyof TTarget];
@@ -72,7 +78,7 @@ export class I18n {
}
public get tsx(): Tsx {
- if (_DEV_) {
+ if (this.devMode) {
if (this.tsxCache) {
return this.tsxCache;
}
@@ -113,7 +119,7 @@ export class I18n {
return () => value;
}
- return (arg) => {
+ return (arg: TODO) => {
let str = quasis[0];
for (let i = 0; i < expressions.length; i++) {
@@ -152,7 +158,7 @@ export class I18n {
const value = target[k as keyof typeof target];
if (typeof value === 'object') {
- result[k] = build(value as ILocale);
+ (result as TODO)[k] = build(value as ILocale);
} else if (typeof value === 'string') {
const quasis: string[] = [];
const expressions: string[] = [];
@@ -179,7 +185,7 @@ export class I18n {
continue;
}
- result[k] = (arg) => {
+ (result as TODO)[k] = (arg: TODO) => {
let str = quasis[0];
for (let i = 0; i < expressions.length; i++) {
@@ -208,9 +214,9 @@ export class I18n {
let str: string | ParameterizedString | ILocale = this.locale;
for (const k of key.split('.')) {
- str = str[k];
+ str = (str as TODO)[k];
- if (_DEV_) {
+ if (this.devMode) {
if (typeof str === 'undefined') {
console.error(`Unexpected locale key: ${key}`);
return key;
@@ -219,7 +225,7 @@ export class I18n {
}
if (args) {
- if (_DEV_) {
+ if (this.devMode) {
const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter));
if (missing.length) {
@@ -230,7 +236,7 @@ export class I18n {
for (const [k, v] of Object.entries(args)) {
const search = `{${k}}`;
- if (_DEV_) {
+ if (this.devMode) {
if (!(str as string).includes(search)) {
console.error(`Unexpected locale parameter: ${k} at ${key}`);
}
diff --git a/packages/frontend-shared/js/media-proxy.ts b/packages/frontend-shared/js/media-proxy.ts
new file mode 100644
index 0000000000..2837870c9a
--- /dev/null
+++ b/packages/frontend-shared/js/media-proxy.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { query } from './url.js';
+
+export class MediaProxy {
+ private serverMetadata: Misskey.entities.MetaDetailed;
+ private url: string;
+
+ constructor(serverMetadata: Misskey.entities.MetaDetailed, url: string) {
+ this.serverMetadata = serverMetadata;
+ this.url = url;
+ }
+
+ public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
+ const localProxy = `${this.url}/proxy`;
+ let _imageUrl = imageUrl;
+
+ if (imageUrl.startsWith(this.serverMetadata.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
+ // もう既にproxyっぽそうだったらurlを取り出す
+ _imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
+ }
+
+ return `${mustOrigin ? localProxy : this.serverMetadata.mediaProxy}/${
+ type === 'preview' ? 'preview.webp'
+ : 'image.webp'
+ }?${query({
+ url: _imageUrl,
+ ...(!noFallback ? { 'fallback': '1' } : {}),
+ ...(type ? { [type]: '1' } : {}),
+ ...(mustOrigin ? { origin: '1' } : {}),
+ })}`;
+ }
+
+ public getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
+ if (imageUrl == null) return null;
+ return this.getProxiedImageUrl(imageUrl, type);
+ }
+
+ public getStaticImageUrl(baseUrl: string): string {
+ const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, this.url);
+
+ if (u.href.startsWith(`${this.url}/emoji/`)) {
+ // もう既にemojiっぽそうだったらsearchParams付けるだけ
+ u.searchParams.set('static', '1');
+ return u.href;
+ }
+
+ if (u.href.startsWith(this.serverMetadata.mediaProxy + '/')) {
+ // もう既にproxyっぽそうだったらsearchParams付けるだけ
+ u.searchParams.set('static', '1');
+ return u.href;
+ }
+
+ return `${this.serverMetadata.mediaProxy}/static.webp?${query({
+ url: u.href,
+ static: '1',
+ })}`;
+ }
+}
diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend-shared/js/scroll.ts
similarity index 98%
rename from packages/frontend/src/scripts/scroll.ts
rename to packages/frontend-shared/js/scroll.ts
index f0274034b5..1062e5252f 100644
--- a/packages/frontend/src/scripts/scroll.ts
+++ b/packages/frontend-shared/js/scroll.ts
@@ -45,7 +45,7 @@ export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, o
const container = getScrollContainer(el) ?? window;
- const onScroll = ev => {
+ const onScroll = () => {
if (!document.body.contains(el)) return;
if (isTopVisible(el, tolerance)) {
cb();
@@ -69,7 +69,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
}
const containerOrWindow = container ?? window;
- const onScroll = ev => {
+ const onScroll = () => {
if (!document.body.contains(el)) return;
if (isBottomVisible(el, 1, container)) {
cb();
diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend-shared/js/url.ts
similarity index 70%
rename from packages/frontend/src/scripts/url.ts
rename to packages/frontend-shared/js/url.ts
index 5a8265af9e..eb830b1eea 100644
--- a/packages/frontend/src/scripts/url.ts
+++ b/packages/frontend-shared/js/url.ts
@@ -8,18 +8,18 @@
* 2. プロパティがundefinedの時はクエリを付けない
* (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない)
*/
-export function query(obj: Record): string {
+export function query(obj: Record): string {
const params = Object.entries(obj)
- .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
- .reduce((a, [k, v]) => (a[k] = v, a), {} as Record);
+ .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) // eslint-disable-line @typescript-eslint/no-unnecessary-condition
+ .reduce>((a, [k, v]) => (a[k] = v, a), {});
return Object.entries(params)
.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
.join('&');
}
-export function appendQuery(url: string, query: string): string {
- return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
+export function appendQuery(url: string, queryString: string): string {
+ return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${queryString}`;
}
export function extractDomain(url: string) {
diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend-shared/js/use-document-visibility.ts
similarity index 85%
rename from packages/frontend/src/scripts/use-document-visibility.ts
rename to packages/frontend-shared/js/use-document-visibility.ts
index a8f4d5e03a..b1197e68da 100644
--- a/packages/frontend/src/scripts/use-document-visibility.ts
+++ b/packages/frontend-shared/js/use-document-visibility.ts
@@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { onMounted, onUnmounted, ref, Ref } from 'vue';
+import { onMounted, onUnmounted, ref } from 'vue';
+import type { Ref } from 'vue';
export function useDocumentVisibility(): Ref {
const visibility = ref(document.visibilityState);
diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend-shared/js/use-interval.ts
similarity index 100%
rename from packages/frontend/src/scripts/use-interval.ts
rename to packages/frontend-shared/js/use-interval.ts
diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json
new file mode 100644
index 0000000000..9981d10dd2
--- /dev/null
+++ b/packages/frontend-shared/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "frontend-shared",
+ "type": "module",
+ "main": "./js-built/index.js",
+ "types": "./js-built/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./js-built/index.js",
+ "types": "./js-built/index.d.ts"
+ },
+ "./*": {
+ "import": "./js-built/*",
+ "types": "./js-built/*"
+ }
+ },
+ "scripts": {
+ "build": "node ./build.js",
+ "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"",
+ "eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
+ "typecheck": "tsc --noEmit",
+ "lint": "pnpm typecheck && pnpm eslint"
+ },
+ "devDependencies": {
+ "@types/node": "20.14.12",
+ "@typescript-eslint/eslint-plugin": "7.17.0",
+ "@typescript-eslint/parser": "7.17.0",
+ "esbuild": "0.23.0",
+ "eslint-plugin-vue": "9.27.0",
+ "typescript": "5.5.4",
+ "vue-eslint-parser": "9.4.3"
+ },
+ "files": [
+ "js-built"
+ ],
+ "dependencies": {
+ "misskey-js": "workspace:*",
+ "vue": "3.4.37"
+ }
+}
diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5
similarity index 100%
rename from packages/frontend/src/themes/_dark.json5
rename to packages/frontend-shared/themes/_dark.json5
diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5
similarity index 100%
rename from packages/frontend/src/themes/_light.json5
rename to packages/frontend-shared/themes/_light.json5
diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5
similarity index 100%
rename from packages/frontend/src/themes/d-astro.json5
rename to packages/frontend-shared/themes/d-astro.json5
diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend-shared/themes/d-botanical.json5
similarity index 100%
rename from packages/frontend/src/themes/d-botanical.json5
rename to packages/frontend-shared/themes/d-botanical.json5
diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend-shared/themes/d-cherry.json5
similarity index 100%
rename from packages/frontend/src/themes/d-cherry.json5
rename to packages/frontend-shared/themes/d-cherry.json5
diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend-shared/themes/d-dark.json5
similarity index 100%
rename from packages/frontend/src/themes/d-dark.json5
rename to packages/frontend-shared/themes/d-dark.json5
diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend-shared/themes/d-future.json5
similarity index 100%
rename from packages/frontend/src/themes/d-future.json5
rename to packages/frontend-shared/themes/d-future.json5
diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend-shared/themes/d-green-lime.json5
similarity index 100%
rename from packages/frontend/src/themes/d-green-lime.json5
rename to packages/frontend-shared/themes/d-green-lime.json5
diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend-shared/themes/d-green-orange.json5
similarity index 100%
rename from packages/frontend/src/themes/d-green-orange.json5
rename to packages/frontend-shared/themes/d-green-orange.json5
diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend-shared/themes/d-ice.json5
similarity index 100%
rename from packages/frontend/src/themes/d-ice.json5
rename to packages/frontend-shared/themes/d-ice.json5
diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend-shared/themes/d-persimmon.json5
similarity index 100%
rename from packages/frontend/src/themes/d-persimmon.json5
rename to packages/frontend-shared/themes/d-persimmon.json5
diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5
similarity index 100%
rename from packages/frontend/src/themes/d-u0.json5
rename to packages/frontend-shared/themes/d-u0.json5
diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend-shared/themes/l-apricot.json5
similarity index 100%
rename from packages/frontend/src/themes/l-apricot.json5
rename to packages/frontend-shared/themes/l-apricot.json5
diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5
similarity index 100%
rename from packages/frontend/src/themes/l-botanical.json5
rename to packages/frontend-shared/themes/l-botanical.json5
diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend-shared/themes/l-cherry.json5
similarity index 100%
rename from packages/frontend/src/themes/l-cherry.json5
rename to packages/frontend-shared/themes/l-cherry.json5
diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend-shared/themes/l-coffee.json5
similarity index 100%
rename from packages/frontend/src/themes/l-coffee.json5
rename to packages/frontend-shared/themes/l-coffee.json5
diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend-shared/themes/l-light.json5
similarity index 100%
rename from packages/frontend/src/themes/l-light.json5
rename to packages/frontend-shared/themes/l-light.json5
diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend-shared/themes/l-rainy.json5
similarity index 100%
rename from packages/frontend/src/themes/l-rainy.json5
rename to packages/frontend-shared/themes/l-rainy.json5
diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend-shared/themes/l-sushi.json5
similarity index 100%
rename from packages/frontend/src/themes/l-sushi.json5
rename to packages/frontend-shared/themes/l-sushi.json5
diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5
similarity index 100%
rename from packages/frontend/src/themes/l-u0.json5
rename to packages/frontend-shared/themes/l-u0.json5
diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5
similarity index 100%
rename from packages/frontend/src/themes/l-vivid.json5
rename to packages/frontend-shared/themes/l-vivid.json5
diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json
new file mode 100644
index 0000000000..fa0b765534
--- /dev/null
+++ b/packages/frontend-shared/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": false,
+ "outDir": "./js-built/",
+ "removeComments": true,
+ "resolveJsonModule": true,
+ "strict": true,
+ "strictFunctionTypes": true,
+ "strictNullChecks": true,
+ "experimentalDecorators": true,
+ "noImplicitReturns": true,
+ "esModuleInterop": true,
+ "typeRoots": [
+ "./node_modules/@types"
+ ],
+ "lib": [
+ "esnext",
+ "dom"
+ ]
+ },
+ "include": [
+ "js/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "test/**/*"
+ ]
+}
diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts
index fb93d7be13..e5573f2ac3 100644
--- a/packages/frontend/.storybook/preload-theme.ts
+++ b/packages/frontend/.storybook/preload-theme.ts
@@ -30,7 +30,7 @@ const keys = [
'd-u0',
]
-await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
+await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
writeFile(
new URL('./themes.ts', import.meta.url),
`export default ${JSON.stringify(
diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts
index 0a7281898d..70afc356c1 100644
--- a/packages/frontend/@types/theme.d.ts
+++ b/packages/frontend/@types/theme.d.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-declare module '@/themes/*.json5' {
+declare module '@@/themes/*.json5' {
import { Theme } from '@/scripts/theme.js';
const theme: Theme;
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 1464be18a7..67be7f0598 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -55,6 +55,7 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
+ "frontend-shared": "workspace:*",
"photoswipe": "5.4.4",
"punycode": "2.3.1",
"rollup": "4.19.1",
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index d86ae18ffe..19d30f64ce 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -22,7 +22,8 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
-import { setupRouter } from '@/router/definition.js';
+import { setupRouter } from '@/router/main.js';
+import { createMainRouter } from '@/router/definition.js';
export async function common(createVue: () => App) {
console.info(`Misskey v${version}`);
@@ -239,7 +240,7 @@ export async function common(createVue: () => App) {
const app = createVue();
- setupRouter(app);
+ setupRouter(app, createMainRouter);
if (_DEV_) {
app.config.performance = true;
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 3e7c4f26f8..b31281dcf2 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -22,6 +22,7 @@ import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js';
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
+import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
@@ -62,6 +63,18 @@ export async function mainBoot() {
}
});
+ stream.on('emojiAdded', emojiData => {
+ addCustomEmoji(emojiData.emoji);
+ });
+
+ stream.on('emojiUpdated', emojiData => {
+ updateCustomEmojis(emojiData.emojis);
+ });
+
+ stream.on('emojiDeleted', emojiData => {
+ removeCustomEmojis(emojiData.emojis);
+ });
+
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
import('@/plugin.js').then(async ({ install }) => {
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue
index 932c4ecb2e..f547991369 100644
--- a/packages/frontend/src/components/MkAutocomplete.vue
+++ b/packages/frontend/src/components/MkAutocomplete.vue
@@ -46,17 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue
index c13164c296..fca7aa2f4e 100644
--- a/packages/frontend/src/components/MkEmojiPicker.section.vue
+++ b/packages/frontend/src/components/MkEmojiPicker.section.vue
@@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
`,
+ ];
+ return iframeCode.join('\n');
+}
+
+/**
+ * 埋め込みコードを生成してコピーする(カスタマイズ機能つき)
+ *
+ * カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください
+ */
+export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
+ const _params = { ...params };
+
+ if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
+ _params.maxHeight = 700;
+ }
+
+ // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
+ if (window.innerWidth < MOBILE_THRESHOLD) {
+ copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
+ os.success();
+ } else {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
+ entity,
+ id,
+ params: _params,
+ }, {
+ closed: () => dispose(),
+ });
+ }
+}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index b5d7350a41..e0ccea813d 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -21,6 +21,7 @@ import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
+import { genEmbedCode } from '@/scripts/get-embed-code.js';
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
@@ -156,6 +157,19 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string):
};
}
+function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined {
+ if (note.url != null || note.uri != null) return undefined;
+ if (['specified', 'followers'].includes(note.visibility)) return undefined;
+
+ return {
+ icon: 'ti ti-code',
+ text,
+ action: (): void => {
+ genEmbedCode('notes', note.id);
+ },
+ };
+}
+
export function getNoteMenu(props: {
note: Misskey.entities.Note;
translation: Ref;
@@ -310,7 +324,7 @@ export function getNoteMenu(props: {
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
- } : undefined,
+ } : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode),
...(isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
@@ -443,14 +457,14 @@ export function getNoteMenu(props: {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
- }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)
- , (appearNote.url || appearNote.uri) ? {
+ }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink),
+ (appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
- } : undefined]
+ } : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)]
.filter(x => x !== undefined);
}
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 33f16a68aa..035abc7bd0 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -17,6 +17,7 @@ import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-pe
import { IRouter } from '@/nirax.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router/main.js';
+import { genEmbedCode } from '@/scripts/get-embed-code.js';
import { MenuItem } from '@/types/menu.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
@@ -179,7 +180,17 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
if (user.url == null) return;
window.open(user.url, '_blank', 'noopener');
},
- }] : []), {
+ }] : [{
+ icon: 'ti ti-code',
+ text: i18n.ts.genEmbedCode,
+ type: 'parent' as const,
+ children: [{
+ text: i18n.ts.noteOfThisUser,
+ action: () => {
+ genEmbedCode('user-timeline', user.id);
+ },
+ }], // TODO: ユーザーカードの埋め込みなど
+ }]), {
icon: 'ti ti-share',
text: i18n.ts.copyProfileUrl,
action: () => {
diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts
index 6b511f2a5f..20f51660c7 100644
--- a/packages/frontend/src/scripts/idb-proxy.ts
+++ b/packages/frontend/src/scripts/idb-proxy.ts
@@ -10,10 +10,11 @@ import {
set as iset,
del as idel,
} from 'idb-keyval';
+import { miLocalStorage } from '@/local-storage.js';
-const fallbackName = (key: string) => `idbfallback::${key}`;
+const PREFIX = 'idbfallback::';
-let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true;
+let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
@@ -38,15 +39,15 @@ if (idbAvailable) {
export async function get(key: string) {
if (idbAvailable) return iget(key);
- return JSON.parse(window.localStorage.getItem(fallbackName(key)));
+ return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
}
export async function set(key: string, val: any) {
if (idbAvailable) return iset(key, val);
- return window.localStorage.setItem(fallbackName(key), JSON.stringify(val));
+ return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
}
export async function del(key: string) {
if (idbAvailable) return idel(key);
- return window.localStorage.removeItem(fallbackName(key));
+ return miLocalStorage.removeItem(`${PREFIX}${key}`);
}
diff --git a/packages/frontend/src/scripts/is-link.ts b/packages/frontend/src/scripts/is-link.ts
new file mode 100644
index 0000000000..946f86400e
--- /dev/null
+++ b/packages/frontend/src/scripts/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/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
index 099a22163a..68a5a1dcf8 100644
--- a/packages/frontend/src/scripts/media-proxy.ts
+++ b/packages/frontend/src/scripts/media-proxy.ts
@@ -3,51 +3,32 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { query } from '@/scripts/url.js';
+import { MediaProxy } from '@@/js/media-proxy.js';
import { url } from '@/config.js';
import { instance } from '@/instance.js';
-export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
- const localProxy = `${url}/proxy`;
+let _mediaProxy: MediaProxy | null = null;
- if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
- // もう既にproxyっぽそうだったらurlを取り出す
- imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
+export function getProxiedImageUrl(...args: Parameters): string {
+ if (_mediaProxy == null) {
+ _mediaProxy = new MediaProxy(instance, url);
}
- return `${mustOrigin ? localProxy : instance.mediaProxy}/${
- type === 'preview' ? 'preview.webp'
- : 'image.webp'
- }?${query({
- url: imageUrl,
- ...(!noFallback ? { 'fallback': '1' } : {}),
- ...(type ? { [type]: '1' } : {}),
- ...(mustOrigin ? { origin: '1' } : {}),
- })}`;
+ return _mediaProxy.getProxiedImageUrl(...args);
}
-export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
- if (imageUrl == null) return null;
- return getProxiedImageUrl(imageUrl, type);
-}
-
-export function getStaticImageUrl(baseUrl: string): string {
- const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, url);
-
- if (u.href.startsWith(`${url}/emoji/`)) {
- // もう既にemojiっぽそうだったらsearchParams付けるだけ
- u.searchParams.set('static', '1');
- return u.href;
+export function getProxiedImageUrlNullable(...args: Parameters): string | null {
+ if (_mediaProxy == null) {
+ _mediaProxy = new MediaProxy(instance, url);
}
- if (u.href.startsWith(instance.mediaProxy + '/')) {
- // もう既にproxyっぽそうだったらsearchParams付けるだけ
- u.searchParams.set('static', '1');
- return u.href;
+ return _mediaProxy.getProxiedImageUrlNullable(...args);
+}
+
+export function getStaticImageUrl(...args: Parameters): string {
+ if (_mediaProxy == null) {
+ _mediaProxy = new MediaProxy(instance, url);
}
- return `${instance.mediaProxy}/static.webp?${query({
- url: u.href,
- static: '1',
- })}`;
+ return _mediaProxy.getStaticImageUrl(...args);
}
diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts
index 9938e534c1..bf59fe98a0 100644
--- a/packages/frontend/src/scripts/mfm-function-picker.ts
+++ b/packages/frontend/src/scripts/mfm-function-picker.ts
@@ -6,7 +6,7 @@
import { Ref, nextTick } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
-import { MFM_TAGS } from '@/const.js';
+import { MFM_TAGS } from '@@/js/const.js';
import type { MenuItem } from '@/types/menu.js';
/**
diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts
index 1caa2dfc21..ed49611b4f 100644
--- a/packages/frontend/src/scripts/popout.ts
+++ b/packages/frontend/src/scripts/popout.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { appendQuery } from './url.js';
+import { appendQuery } from '@@/js/url.js';
import * as config from '@/config.js';
export function popout(path: string, w?: HTMLElement) {
diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts
index 31a9ac1ad9..11b6f52ddd 100644
--- a/packages/frontend/src/scripts/post-message.ts
+++ b/packages/frontend/src/scripts/post-message.ts
@@ -18,7 +18,7 @@ export type MiPostMessageEvent = {
* 親フレームにイベントを送信
*/
export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
- window.postMessage({
+ window.parent.postMessage({
type,
payload,
}, '*');
diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts
deleted file mode 100644
index 6bfcef6c36..0000000000
--- a/packages/frontend/src/scripts/safe-parse.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function safeParseFloat(str: unknown): number | null {
- if (typeof str !== 'string' || str === '') return null;
- const num = parseFloat(str);
- if (isNaN(num)) return null;
- return num;
-}
diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts
deleted file mode 100644
index 0edf4e9eba..0000000000
--- a/packages/frontend/src/scripts/safe-uri-decode.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function safeURIDecode(str: string): string {
- try {
- return decodeURIComponent(str);
- } catch {
- return str;
- }
-}
diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/scripts/stream-mock.ts
new file mode 100644
index 0000000000..cb0e607fcb
--- /dev/null
+++ b/packages/frontend/src/scripts/stream-mock.ts
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { EventEmitter } from 'eventemitter3';
+import * as Misskey from 'misskey-js';
+import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js';
+
+type AnyOf> = T[keyof T];
+type OmitFirst = T extends [any, ...infer R] ? R : never;
+
+/**
+ * Websocket無効化時に使うStreamのモック(なにもしない)
+ */
+export class StreamMock extends EventEmitter implements IStream {
+ public readonly state = 'initializing';
+
+ constructor(...args: ConstructorParameters) {
+ super();
+ // do nothing
+ }
+
+ public useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock {
+ return new ChannelConnectionMock(this, channel, name);
+ }
+
+ public removeSharedConnection(connection: any): void {
+ // do nothing
+ }
+
+ public removeSharedConnectionPool(pool: any): void {
+ // do nothing
+ }
+
+ public disconnectToChannel(): void {
+ // do nothing
+ }
+
+ public send(typeOrPayload: string): void
+ public send(typeOrPayload: string, payload: any): void
+ public send(typeOrPayload: Record | any[]): void
+ public send(typeOrPayload: string | Record | any[], payload?: any): void {
+ // do nothing
+ }
+
+ public ping(): void {
+ // do nothing
+ }
+
+ public heartbeat(): void {
+ // do nothing
+ }
+
+ public close(): void {
+ // do nothing
+ }
+}
+
+class ChannelConnectionMock = any> extends EventEmitter implements IChannelConnection {
+ public id = '';
+ public name?: string; // for debug
+ public inCount = 0; // for debug
+ public outCount = 0; // for debug
+ public channel: string;
+
+ constructor(stream: IStream, ...args: OmitFirst>>) {
+ super();
+
+ this.channel = args[0];
+ this.name = args[1];
+ }
+
+ public send(type: T, body: Channel['receives'][T]): void {
+ // do nothing
+ }
+
+ public dispose(): void {
+ // do nothing
+ }
+}
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index c7f8b3d596..9b9f1f030c 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -5,11 +5,11 @@
import { ref } from 'vue';
import tinycolor from 'tinycolor2';
+import lightTheme from '@@/themes/_light.json5';
+import darkTheme from '@@/themes/_dark.json5';
import { deepClone } from './clone.js';
import type { BundledTheme } from 'shiki/themes';
import { globalEvents } from '@/events.js';
-import lightTheme from '@/themes/_light.json5';
-import darkTheme from '@/themes/_dark.json5';
import { miLocalStorage } from '@/local-storage.js';
export type Theme = {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 437314074a..0bf499bb4d 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -458,10 +458,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
- contextMenu: {
+ contextMenu: {
where: 'device',
default: 'app' as 'app' | 'appWithShift' | 'native',
- },
+ },
sound_masterVolume: {
where: 'device',
@@ -520,8 +520,8 @@ interface Watcher {
/**
* 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ)
*/
-import lightTheme from '@/themes/l-light.json5';
-import darkTheme from '@/themes/d-green-lime.json5';
+import lightTheme from '@@/themes/l-light.json5';
+import darkTheme from '@@/themes/d-green-lime.json5';
export class ColdDeviceStorage {
public static default = {
@@ -558,7 +558,7 @@ export class ColdDeviceStorage {
public static set(key: T, value: typeof ColdDeviceStorage.default[T]): void {
// 呼び出し側のバグ等で undefined が来ることがある
// undefined を文字列として miLocalStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+
if (value === undefined) {
console.error(`attempt to store undefined value for key '${key}'`);
return;
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index 0d5bd78b09..9d7edce890 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -7,17 +7,20 @@ import * as Misskey from 'misskey-js';
import { markRaw } from 'vue';
import { $i } from '@/account.js';
import { wsOrigin } from '@/config.js';
+// TODO: No WebsocketモードでStreamMockが使えそう
+//import { StreamMock } from '@/scripts/stream-mock.js';
// heart beat interval in ms
const HEART_BEAT_INTERVAL = 1000 * 60;
-let stream: Misskey.Stream | null = null;
-let timeoutHeartBeat: ReturnType | null = null;
+let stream: Misskey.IStream | null = null;
+let timeoutHeartBeat: number | null = null;
let lastHeartbeatCall = 0;
-export function useStream(): Misskey.Stream {
+export function useStream(): Misskey.IStream {
if (stream) return stream;
+ // TODO: No Websocketモードもここで判定
stream = markRaw(new Misskey.Stream(wsOrigin, $i ? {
token: $i.token,
} : null));
diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue
index 8dad666623..e234bb3a33 100644
--- a/packages/frontend/src/ui/_common_/statusbar-federation.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue
@@ -35,7 +35,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
const props = defineProps<{
diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue
index 6e1d06eec1..550fc39b00 100644
--- a/packages/frontend/src/ui/_common_/statusbar-rss.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
import { shuffle } from '@/scripts/shuffle.js';
const props = defineProps<{
diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
index 67f8b109c4..078b595dca 100644
--- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue
+++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue
@@ -35,7 +35,7 @@ import { ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MarqueeText from '@/components/MkMarquee.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
-import { useInterval } from '@/scripts/use-interval.js';
+import { useInterval } from '@@/js/use-interval.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js';
import { notePage } from '@/filters/note.js';
diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue
index 79c9671917..e7ecf7fd20 100644
--- a/packages/frontend/src/ui/deck/main-column.vue
+++ b/packages/frontend/src/ui/deck/main-column.vue
@@ -26,7 +26,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { useScrollPositionManager } from '@/nirax.js';
-import { getScrollContainer } from '@/scripts/scroll.js';
+import { getScrollContainer } from '@@/js/scroll.js';
import { mainRouter } from '@/router/main.js';
defineProps<{
diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue
index 073acbd4db..00a6811fc9 100644
--- a/packages/frontend/src/ui/universal.vue
+++ b/packages/frontend/src/ui/universal.vue
@@ -108,7 +108,7 @@ import { $i } from '@/account.js';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { miLocalStorage } from '@/local-storage.js';
-import { CURRENT_STICKY_BOTTOM } from '@/const.js';
+import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js';
import { useScrollPositionManager } from '@/nirax.js';
import { mainRouter } from '@/router/main.js';
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index 49fd103d37..bcfaaf00ab 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only