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 @@
- - - - - - + + @@ -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'}