diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index fb76f07e48..9e55a5674f 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -476,6 +476,36 @@ export class ClientServerService {
}
});
+ // Note Embed
+ fastify.get<{ Params: { note: string; } }>('/notes/:note/embed', async (request, reply) => {
+ reply.removeHeader('X-Frame-Options');
+ reply.header("X-Robots-Tag", "noindex");
+
+ const note = await this.notesRepository.findOneBy({
+ id: request.params.note,
+ visibility: In(['public', 'home']),
+ });
+
+ if (note) {
+ const _note = await this.noteEntityService.pack(note);
+ const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
+ const meta = await this.metaService.fetch();
+ reply.header('Cache-Control', 'public, max-age=15');
+ return await reply.view('note', {
+ note: _note,
+ profile,
+ avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })),
+ // TODO: Let locale changeable by instance setting
+ summary: getNoteSummary(_note),
+ instanceName: meta.name ?? 'Misskey',
+ icon: meta.iconUrl,
+ themeColor: meta.themeColor,
+ });
+ } else {
+ return await renderBase(reply);
+ }
+ });
+
// Page
fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => {
const { username, host } = Acct.parse(request.params.user);
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 2d96d5514e..436333ae27 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -39,6 +39,7 @@
"eventemitter3": "5.0.0",
"gsap": "3.11.5",
"idb-keyval": "6.2.0",
+ "iframe-resizer": "^4.3.6",
"insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
@@ -97,6 +98,7 @@
"@types/estree": "^1.0.0",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
+ "@types/iframe-resizer": "^3.5.9",
"@types/matter-js": "0.18.2",
"@types/node": "18.15.11",
"@types/punycode": "2.1.0",
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 67bdfd2258..3459b70005 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -54,6 +54,11 @@
@@ -91,32 +96,54 @@
-
-
-
-
-
-
+
+
+
+ {{ appearNote.repliesCount }}
+
+
+
+ {{ appearNote.renoteCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -158,6 +185,8 @@ import { defaultStore, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account';
+import { instance } from '@/instance';
+import { openInstanceMenu } from '@/ui/_common_/common';
import { i18n } from '@/i18n';
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
@@ -170,6 +199,7 @@ import MkRippleEffect from '@/components/MkRippleEffect.vue';
const props = defineProps<{
note: misskey.entities.Note;
pinned?: boolean;
+ embed?: boolean;
}>();
const inChannel = inject('inChannel', null);
@@ -378,12 +408,12 @@ function onContextmenu(ev: MouseEvent): void {
ev.preventDefault();
react();
} else {
- os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus);
+ os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, embed: props.embed }), ev).then(focus);
}
}
function menu(viaKeyboard = false): void {
- os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, {
+ os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, embed: props.embed }), menuButton.value, {
viaKeyboard,
}).then(focus);
}
@@ -464,6 +494,10 @@ if (appearNote.replyId) {
&:hover > .article > .main > .footer > .button {
opacity: 1;
+
+ &:hover {
+ text-decoration: none;
+ }
}
> .reply-to {
@@ -578,6 +612,19 @@ if (appearNote.replyId) {
word-wrap: anywhere;
}
}
+
+ > .instance-info {
+ flex-shrink: 0;
+ padding-left: 16px;
+ width: 39px;
+ height: 39px;
+
+ img {
+ width: 100%;
+ height: auto;
+ border-radius: 4px;
+ }
+ }
}
> .main {
@@ -734,6 +781,10 @@ if (appearNote.replyId) {
width: 50px;
height: 50px;
}
+ > .instance-info {
+ width: 33px;
+ height: 33px;
+ }
}
> .main {
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 40d134dffb..a4e2cc4e54 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -79,6 +79,11 @@ function popout() {
}
function nav(ev: MouseEvent) {
+ if (router.currentRoute.value.name?.toLowerCase().includes("embed")) {
+ window.open(props.to, '_blank');
+ return;
+ }
+
if (props.behavior === 'browser') {
location.href = props.to;
return;
diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts
index 26c5adfc70..f0836c48b8 100644
--- a/packages/frontend/src/init.ts
+++ b/packages/frontend/src/init.ts
@@ -187,6 +187,7 @@ try {
} catch (err) {}
const app = createApp(
+ window.location.href.includes("/embed") ? defineAsyncComponent(() => import('@/ui/embed.vue')) :
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
diff --git a/packages/frontend/src/pages/note-embed.vue b/packages/frontend/src/pages/note-embed.vue
new file mode 100644
index 0000000000..b823d3fad1
--- /dev/null
+++ b/packages/frontend/src/pages/note-embed.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index c8077edd28..964d46608f 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -30,6 +30,10 @@ export const routes = [{
name: 'note',
path: '/notes/:noteId',
component: page(() => import('./pages/note.vue')),
+}, {
+ name: 'noteEmbed',
+ path: '/notes/:noteId/embed',
+ component: page(() => import('./pages/note-embed.vue')),
}, {
path: '/clips/:clipId',
component: page(() => import('./pages/clip.vue')),
diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/scripts/get-embed-code.ts
new file mode 100644
index 0000000000..4338dc14a4
--- /dev/null
+++ b/packages/frontend/src/scripts/get-embed-code.ts
@@ -0,0 +1,29 @@
+import { url } from '@/config';
+import { v4 as uuid } from 'uuid';
+
+/**
+ * 埋め込みコードを出力します。
+ */
+export function getEmbedCode(props: {
+ entityType: 'notes';
+ id: string;
+}): string | null {
+ let src: string | undefined = "";
+
+ switch (props.entityType) {
+ case 'notes':
+ src = `${url}/notes/${props.id}/embed`;
+ break;
+ default:
+ src = undefined;
+ }
+
+ if (src !== undefined) {
+ const id = uuid();
+ return `
+
+`;
+ }
+
+ return null;
+}
\ No newline at end of file
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index d91f0b0eb6..b19aaf74e9 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -11,6 +11,7 @@ import { noteActions } from '@/store';
import { miLocalStorage } from '@/local-storage';
import { getUserMenu } from '@/scripts/get-user-menu';
import { clipsCache } from '@/cache';
+import { getEmbedCode } from './get-embed-code';
export async function getNoteClipMenu(props: {
note: misskey.entities.Note;
@@ -93,6 +94,7 @@ export function getNoteMenu(props: {
translating: Ref
;
isDeleted: Ref;
currentClip?: misskey.entities.Clip;
+ embed?: boolean;
}) {
const isRenote = (
props.note.renote != null &&
@@ -202,7 +204,17 @@ export function getNoteMenu(props: {
}
function openDetail(): void {
- os.pageWindow(`/notes/${appearNote.id}`);
+ if (props.embed) {
+ window.open(`/notes/${appearNote.id}`, "_blank");
+ } else {
+ os.pageWindow(`/notes/${appearNote.id}`);
+ }
+ }
+
+ function copyEmbedCode(): void {
+ console.log(getEmbedCode({ entityType: 'notes', id: appearNote.id }));
+ copyToClipboard(getEmbedCode({entityType: 'notes', id: appearNote.id}));
+ os.success();
}
function showReactions(): void {
@@ -223,7 +235,7 @@ export function getNoteMenu(props: {
}
let menu;
- if ($i) {
+ if ($i && !props.embed) {
const statePromise = os.api('notes/state', {
noteId: appearNote.id,
});
@@ -264,6 +276,11 @@ export function getNoteMenu(props: {
text: i18n.ts.share,
action: share,
},
+ (!props.embed) ? {
+ icon: 'ti ti-code',
+ text: "Embed",
+ action: copyEmbedCode,
+ } : undefined,
instance.translatorAvailable ? {
icon: 'ti ti-language-hiragana',
text: i18n.ts.translate,
@@ -356,7 +373,7 @@ export function getNoteMenu(props: {
} else {
menu = [{
icon: 'ti ti-external-link',
- text: i18n.ts.detailed,
+ text: i18n.ts.details,
action: openDetail,
}, {
icon: 'ti ti-copy',
@@ -372,7 +389,11 @@ export function getNoteMenu(props: {
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank');
},
- } : undefined]
+ } : undefined, (!props.embed) ? {
+ icon: 'ti ti-code',
+ text: "Embed",
+ action: copyEmbedCode,
+ } : undefined,]
.filter(x => x !== undefined);
}
diff --git a/packages/frontend/src/ui/embed.vue b/packages/frontend/src/ui/embed.vue
new file mode 100644
index 0000000000..dffc56715f
--- /dev/null
+++ b/packages/frontend/src/ui/embed.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a130bb12c6..98d44b51fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -663,6 +663,9 @@ importers:
idb-keyval:
specifier: 6.2.0
version: 6.2.0
+ iframe-resizer:
+ specifier: ^4.3.6
+ version: 4.3.6
insert-text-at-cursor:
specifier: 0.3.0
version: 0.3.0
@@ -832,6 +835,9 @@ importers:
'@types/gulp-rename':
specifier: 2.0.1
version: 2.0.1
+ '@types/iframe-resizer':
+ specifier: ^3.5.9
+ version: 3.5.9
'@types/matter-js':
specifier: 0.18.2
version: 0.18.2
@@ -6614,6 +6620,10 @@ packages:
/@types/http-cache-semantics@4.0.1:
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
+ /@types/iframe-resizer@3.5.9:
+ resolution: {integrity: sha512-RQUBI75F+uXruB95BFpC/8V8lPgJg4MQ6HxOCtAZYBB/h0FNCfrFfb4I+u2pZJIV7sKeszZbFqy1UnGeBMrvsA==}
+ dev: true
+
/@types/ioredis@4.28.10:
resolution: {integrity: sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==}
dependencies:
@@ -12590,6 +12600,11 @@ packages:
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+ /iframe-resizer@4.3.6:
+ resolution: {integrity: sha512-wz0WodRIF6eP0oGQa5NIP1yrITAZ59ZJvVaVJqJRjaeCtfm461vy2C3us6CKx0e7pooqpIGLpVMSTzrfAjX9Sg==}
+ engines: {node: '>=0.8.0'}
+ dev: false
+
/ignore@5.2.4:
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
engines: {node: '>= 4'}