diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index ed92b16ebe..46791f4250 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -489,7 +489,7 @@ export class ClientServerService { // Note Embed fastify.get<{ Params: { note: string; } }>('/notes/:note/embed', async (request, reply) => { - vary(reply.raw, 'Accept'); + reply.removeHeader('X-Frame-Options'); const note = await this.notesRepository.findOneBy({ id: request.params.note, diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index d59f00fe16..421597e6a4 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -11,7 +11,7 @@ html { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--bg, #fff); opacity: 1; transition: opacity 0.5s ease; } diff --git a/packages/backend/src/server/web/views/embed/note.pug b/packages/backend/src/server/web/views/embed/note.pug index cb951f55cb..15a1247e90 100644 --- a/packages/backend/src/server/web/views/embed/note.pug +++ b/packages/backend/src/server/web/views/embed/note.pug @@ -4,16 +4,19 @@ block vars - const user = note.user; - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; - const url = `${config.url}/notes/${note.id}`; + - const userUrl = (user) => user.host ? `${config.url}/@${user.username}@${user.host}` : `${config.url}/@${user.username}`; - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; - const displayUser = isRenote ? note.renote.user: note.user; + - const mainNote = isRenote ? note.renote : note; + - const displayMedia = mainNote.files.filter((item) => item.type.startsWith('image') || item.type.startsWith('video')).slice(0, 4); + - const noteEmoji = Object.entries(note.emojis || {}).map((e) => { return { name: e[0], url: e[1], host: note.user.host }; }).concat(Object.entries(note.renote?.emojis || {}).map((e) => { return { name: e[0], url: e[1], host: note.renote?.user.host }; })) block meta if !user.host link(rel='alternate' href=url type='application/activity+json') if note.uri link(rel='alternate' href=note.uri type='application/activity+json') - - script(type='application/json') !{JSON.stringify(note)} + script(id='remote_custom_emojis' type='application/json') !{JSON.stringify(noteEmoji)} block content div#note @@ -22,22 +25,24 @@ block content div.wrapper if isRenote div#renote - a.avatar(href=`${config.url}/@${note.user.username}` target="_blank" rel="noopener noreferrer") + a.avatar(href=userUrl(note.user) target="_blank" rel="noopener noreferrer") img(src=note.user.avatarUrl) i.ti.ti-repeat span(data-mi-i18n='renotedBy' data-mi-i18n-ctx=`{"user": "${note.user.name || note.user.username}"}`) - a(href=`${config.url}/@${note.user.username}` target="_blank" rel="noopener noreferrer") + a(href=userUrl(note.user) target="_blank" rel="noopener noreferrer") b(data-mi-i18n-target='user') div.author - a.avatar(href=`${config.url}/@${displayUser.username}` target="_blank" rel="noopener noreferrer") + a.avatar(href=userUrl(displayUser) target="_blank" rel="noopener noreferrer") img(src=displayUser.avatarUrl) div.user-info - a.user-name(href=`${config.url}/@${displayUser.username}` target="_blank" rel="noopener noreferrer") #{displayUser.name || displayUser.username} + a.user-name(href=userUrl(displayUser) target="_blank" rel="noopener noreferrer") #{displayUser.name || displayUser.username} div.user-id @#{displayUser.username} + if displayUser.host + span(style="opacity: .5;") @#{displayUser.host} div#instance-info a.click-anime(href=config.url target='_blank') @@ -45,9 +50,77 @@ block content span.sr-only(data-mi-i18n='aboutX' data-mi-i18n-ctx=`{"x": "${instanceName}"}`) main - div.mfm !{isRenote ? note.renote.text : note.text} + if (mainNote.cw != null) + div.cw-meta + span.mfm.cw-summary #{mainNote.cw} + button._button#cw-button + span#cw-info-show + b(data-mi-i18n="showMore") + span.cw-info-wrapper + if(mainNote.text) + span.cw-info(data-mi-i18n='_cw.chars' data-mi-i18n-ctx=`{"count": ${mainNote.text.length}}`) + if(mainNote.files && mainNote.files.length !== 0) + span.cw-info(data-mi-i18n='_cw.files' data-mi-i18n-ctx=`{"count": ${mainNote.files.length}}`) + if(mainNote.poll != null) + span.cw-info(data-mi-i18n='poll') + span.hide#cw-info-hide + b(data-mi-i18n="hide") + div#note-body.mfm.hide #{mainNote.text} + + else + div#note-body.mfm #{mainNote.text} + if (!isRenote && note.renote) - div#quote.mfm !{note.renote.text} - - hr - pre(style='white-space: pre-wrap;') !{JSON.stringify(note, null, 2)} + div#quote + div.quote-avatar + a.avatar(href=userUrl(note.renote.user) target="_blank" rel="noopener noreferrer") + img(src=note.renote.user.avatarUrl) + + div.quote-body + div.meta + div.author + a(href=userUrl(note.renote.user) target="_blank" rel="noopener noreferrer") + b #{note.renote.user.name || note.renote.user.username} + span @#{note.renote.user.username} + if note.renote.user.host + span(style="opacity: .5;") @#{note.renote.user.host} + + a.time(href=`${config.url}/notes/${note.renote.id}` target="_blank" rel="noopener noreferrer") + time.locale-string(datetime=note.renote.createdAt data-mi-date-mode="relative") + + if (note.renote.cw != null) + div.cw-meta + span.mfm.cw-summary #{note.renote.cw} + button._button#quote-cw-button + span#quote-cw-info-show + b(data-mi-i18n="showMore") + span.cw-info-wrapper + if(mainNote.text) + span.cw-info(data-mi-i18n='_cw.chars' data-mi-i18n-ctx=`{"count": ${mainNote.text.length}}`) + if(mainNote.files && mainNote.files.length !== 0) + span.cw-info(data-mi-i18n='_cw.files' data-mi-i18n-ctx=`{"count": ${mainNote.files.length}}`) + if(mainNote.poll != null) + span.cw-info(data-mi-i18n='poll') + span.hide#quote-cw-info-hide + b(data-mi-i18n="hide") + div#quote-note-body.mfm.hide #{mainNote.text} + else + div#quote-note-body.mfm #{note.renote.text} + footer + div.info + a(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer") + time.locale-string(datetime=mainNote.createdAt data-mi-date-mode="detailed") + div.reactions + each val, key in mainNote.reactions + span.reaction-item + span.emoji #{key} + span.count #{val} + + a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer") + i.ti.ti-arrow-back-up + a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer") + i.ti.ti-repeat + a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer") + i.ti.ti-plus + a._button.button(href=`${config.url}/notes/${mainNote.id}`, target="_blank", rel="noopener noreferrer") + i.ti.ti-dots diff --git a/packages/frontend/src/embed/README.md b/packages/frontend/src/embed/README.md new file mode 100644 index 0000000000..0d2f97f1a9 --- /dev/null +++ b/packages/frontend/src/embed/README.md @@ -0,0 +1,9 @@ +# Misskey 埋め込みのカスタマイズについて + +URLパラメータで、埋め込みの形式をカスタマイズできます。 + +## 共通 + +- `rounded` … 外枠を丸める。`0`で無効(デフォルト…`1`) +- `theme` … プリインストールテーマのID、もしくは `light` `dark` を指定可能。指定なしの場合は、最後に使用したテーマもしくはデバイスのカラーモードに応じたデフォルトテーマを適用。(デフォルト:指定なし) +- `mfm` … `animated` を指定可能。指定すると、動きのあるMFMが有効になります。(デフォルト…指定なし) \ No newline at end of file diff --git a/packages/frontend/src/embed/embed.scss b/packages/frontend/src/embed/embed.scss index bd1da0e817..fcd886c061 100644 --- a/packages/frontend/src/embed/embed.scss +++ b/packages/frontend/src/embed/embed.scss @@ -16,18 +16,171 @@ html,body { width: auto; } -.custom-emoji { +.hide { + display: none; +} + +.emoji { height: 2em; width: auto; - display: inline-block; + display: inline; vertical-align: center; transition: transform .2s ease; + &.custom-emoji>img,svg { + height: 2em; + vertical-align: middle; + } + + &.unicode-emoji>img,svg { + height: 1.25em; + vertical-align: -.25em; + } + &:hover { transform: scale(1.2); } } +.mfm { + white-space: pre-wrap; + + ._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%; + } + } + } + + @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%); } + } +} + #error { padding: 0 0 3.5rem; text-align: center; @@ -52,74 +205,4 @@ html,body { p { } -} - -#note { - display: block; - position: relative; - - header { - display: flex; - margin-bottom: 1rem; - - .wrapper { - #renote { - display: flex; - align-items: center; - margin-bottom: .5rem; - color: var(--renote); - - >.avatar { - display: block; - margin-right: 1rem; - - >img { - width: 28px; - height: 28px; - border-radius: 50%; - } - } - } - - .author { - display: flex; - align-items: center; - - >.avatar { - display: block; - margin-right: 1rem; - - >img { - width: 54px; - height: 54px; - border-radius: 50%; - } - } - - >.user-info { - >.user-name { - font-weight: 700; - font-size: 1.1rem; - } - } - } - } - - #instance-info { - flex-shrink: 0; - margin-left: auto; - } - } - - main { - font-size: 1.05rem; - - #quote { - font-size: .95rem; - padding: .75rem; - margin: 1rem 0; - border-radius: var(--radius); - border: dashed 1px var(--renote); - } - } } \ No newline at end of file diff --git a/packages/frontend/src/embed/init.ts b/packages/frontend/src/embed/init.ts index e583bf38e5..2217de1741 100644 --- a/packages/frontend/src/embed/init.ts +++ b/packages/frontend/src/embed/init.ts @@ -1,15 +1,25 @@ +import JSON5 from 'json5'; import { miLocalStorage } from '@/local-storage'; -import { version, lang, updateLocale } from '@/config'; -import { updateI18n } from '@/i18n'; +import { version, lang, updateLocale, url } from '@/config'; import { embedInitI18n } from './scripts/embed-i18n'; import '@/style.scss'; import './embed.scss'; import 'iframe-resizer/js/iframeResizer.contentWindow'; import { embedInitLinkAnime } from './scripts/link-anime'; import { parseMfm } from './scripts/parse-mfm'; +import { renderNotFound } from './scripts/render-not-found'; +import { parseEmoji } from './scripts/parse-emoji'; +import { applyTheme } from './scripts/theme'; +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; console.info(`Misskey (Embed Sandbox) v${version}`); +const supportedEmbedEntity: string[] = [ + 'notes' +]; + if (_DEV_) { console.warn('Development mode!!!'); @@ -47,7 +57,7 @@ if (localeOutdated) { miLocalStorage.setItem('locale', newLocale); miLocalStorage.setItem('localeVersion', version); updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); + embedInitI18n(); } } //#endregion @@ -60,23 +70,85 @@ const html = document.documentElement; html.setAttribute('lang', lang); //#endregion -embedInitI18n(); +//#region ページのパスをパース +// パス構造: /{entityName}/{id}/embed +const path = location.pathname; +if (!path.includes('/embed')) { + location.href = url; + throw new Error('Embed script was loaded on non-embed page. Force redirect to the top page.'); +} +const pageMetaValues:string[] = path.split('/').filter((e) => e != '' && e != 'embed'); +const pageMeta: { entityName: string; id: string; } = { + entityName: pageMetaValues[0], + id: pageMetaValues[1], +}; +const URLParams = new URLSearchParams(location.search); +const enableAnimatedMfm = URLParams.get("mfm") === 'animated'; +const disableRootRound = URLParams.get("rounded") === "0"; +//#endregion -document.querySelectorAll(".mfm").forEach((e) => { - e.innerHTML = parseMfm(e.innerHTML).outerHTML; -}); +const rootEl = document.getElementById("container"); +if (disableRootRound && rootEl) { + rootEl.style.borderRadius = "0"; +} -//#region ロード画面解除 -const splash = document.getElementById('splash'); -// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) -if (splash) splash.addEventListener('transitionend', () => { - splash.remove(); -}); +//#region テーマ適用 +const themePreferences = localStorage.getItem('theme'); +const getTheme = async () => { + // テーマが指定されている場合 + if (URLParams.has("theme")) { + switch (URLParams.get("theme")) { + case 'light': + return lightTheme; + case 'dark': + return darkTheme; + default: + return await import(`../themes/${URLParams.get("theme")}.json5`).catch((_) => { + return isDeviceDarkmode() ? darkTheme : lightTheme; + }); + } + } else { + return isDeviceDarkmode() ? darkTheme : lightTheme; + } +}; -if (splash) { - splash.style.opacity = '0'; - splash.style.pointerEvents = 'none'; +if (!themePreferences || URLParams.has("theme")) { + applyTheme(await getTheme(), false); } //#endregion -embedInitLinkAnime(); +//埋め込みページごとのスクリプト読み込み +if (!supportedEmbedEntity.includes(pageMeta.entityName)) { + renderNotFound(); + afterPageInitialization(); +} else { + import(`./pages/${pageMeta.entityName}.ts`).then(() => { + afterPageInitialization(); + }); +} + +function afterPageInitialization() { + embedInitI18n(); + + //@ts-ignore + document.querySelectorAll(".mfm").forEach((e: HTMLElement) => { + e.innerHTML = parseMfm(e.innerText, enableAnimatedMfm).outerHTML; + }); + + parseEmoji(); + + //#region ロード画面解除 + const splash = document.getElementById('splash'); + // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) + if (splash) splash.addEventListener('transitionend', () => { + splash.remove(); + }); + + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + } + //#endregion + + embedInitLinkAnime(); +} \ No newline at end of file diff --git a/packages/frontend/src/embed/notes.scss b/packages/frontend/src/embed/notes.scss new file mode 100644 index 0000000000..d29c09a70b --- /dev/null +++ b/packages/frontend/src/embed/notes.scss @@ -0,0 +1,178 @@ +#note { + display: block; + position: relative; + + header { + display: flex; + margin-bottom: 1rem; + + .wrapper { + #renote { + display: flex; + align-items: center; + margin-bottom: .5rem; + color: var(--renote); + + >.avatar { + display: block; + margin-right: 1rem; + + >img { + width: 28px; + height: 28px; + border-radius: 50%; + } + } + } + + .author { + display: flex; + align-items: center; + + >.avatar { + display: block; + margin-right: 1rem; + + >img { + width: 54px; + height: 54px; + border-radius: 50%; + } + } + + >.user-info { + >.user-name { + font-weight: 700; + font-size: 1.1rem; + } + } + } + } + + #instance-info { + flex-shrink: 0; + margin-left: auto; + } + } + + main { + #note-body { + position: relative; + font-size: 1.05rem; + overflow-wrap: break-word; + } + + #quote { + font-size: .95rem; + padding: 1rem; + margin: 1rem 0; + border-radius: var(--radius); + border: dashed 1px var(--renote); + display: flex; + + .quote-avatar { + margin-right: .5rem; + flex-shrink: 0; + + img { + width: 48px; + height: 48px; + border-radius: 50%; + } + } + + .quote-body { + flex-grow: 1; + + >.meta { + display: flex; + + >.author>a { + display: inline-block; + margin-right: .25rem; + } + + >a.time { + margin-left: auto; + } + } + } + } + + .cw-meta { + margin-bottom: .5em; + + >.cw-summary>div { + display: inline-block; + margin-right: 8px; + } + + #cw-button, + #quote-cw-button { + display: inline-block; + padding: .25em .5em; + font-size: 0.75em; + color: var(--cwFg); + background: var(--cwBg); + border-radius: 2px; + + &:hover { + background: var(--cwHoverBg); + } + + .cw-info-wrapper { + >:first-child { + margin-left: 4px; + &:before { + content: '('; + } + } + + >:after { + content: ' / '; + } + + >:last-child:after { + content: ')'; + } + } + } + } + } + + footer { + .info { + margin: 1rem 0; + opacity: .7; + } + .reactions { + .reaction-item { + border-radius: 4px; + padding: 0 6px; + margin: 2px; + height: 32px; + background-color: var(--fgOnAccent); + display: inline-block; + + img { + height: 1.25rem; + vertical-align: -.25rem; + } + span { + display: inline-block; + margin-left: 4px; + line-height: 32px; + } + } + } + .button { + margin-right: 2rem; + padding: .5rem; + font-size: 1.2rem; + + &:hover { + text-decoration: none; + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/src/embed/pages/notes.ts b/packages/frontend/src/embed/pages/notes.ts new file mode 100644 index 0000000000..19f73b964b --- /dev/null +++ b/packages/frontend/src/embed/pages/notes.ts @@ -0,0 +1,60 @@ +import { embedI18n as i18n } from '../scripts/embed-i18n'; +import { dateTimeFormat } from '@/scripts/intl-const'; +import '../notes.scss'; + +//CWボタン +document.getElementById("cw-button")?.addEventListener("click", () => { + document.getElementById("note-body")?.classList.toggle("hide"); + document.getElementById("cw-info-show")?.classList.toggle("hide"); + document.getElementById("cw-info-hide")?.classList.toggle("hide"); +}); +document.getElementById("quote-cw-button")?.addEventListener("click", () => { + document.getElementById("quote-note-body")?.classList.toggle("hide"); + document.getElementById("quote-cw-info-show")?.classList.toggle("hide"); + document.getElementById("quote-cw-info-hide")?.classList.toggle("hide"); +}); + +//時刻(タイムゾーン関連がややこしいのでJSでレンダリング) +document.querySelectorAll("time.locale-string").forEach((el) => { + const dateTimeRaw = el.getAttribute("datetime"); + const mode = el.getAttribute("data-mi-date-mode"); + if (dateTimeRaw !== null) { + const _time = new Date(dateTimeRaw).getTime(); + + const invalid = Number.isNaN(_time); + const absolute:string = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; + let now = new Date().getTime(); + + const relative = () => { + if (invalid) return i18n.ts._ago.invalid; + + const ago = (now - _time) / 1000/*ms*/; + + return ( + ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : + ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : + ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : + ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : + ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : + ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? i18n.ts._ago.justNow : + i18n.ts._ago.future); + }; + + switch (mode) { + case 'absolute': + el.innerHTML = absolute; + break; + case 'detailed': + el.innerHTML = `${absolute} (${relative()})`; + break; + case 'relative': + default: + el.innerHTML = relative(); + break; + } + } +}); + +export { }; \ No newline at end of file diff --git a/packages/frontend/src/embed/scripts/embed-i18n.ts b/packages/frontend/src/embed/scripts/embed-i18n.ts index 72104e081f..b88cddc96f 100644 --- a/packages/frontend/src/embed/scripts/embed-i18n.ts +++ b/packages/frontend/src/embed/scripts/embed-i18n.ts @@ -1,4 +1,5 @@ -import { i18n } from "@/i18n"; +import { locale } from "@/config"; +import { I18n } from "@/scripts/i18n"; /** * 非vueページ向け翻訳適用関数 @@ -8,6 +9,10 @@ import { i18n } from "@/i18n"; * * ``` */ +const i18n = new I18n(locale); + +export const embedI18n = i18n; + export function embedInitI18n() { const els: NodeListOf = document.querySelectorAll("[data-mi-i18n]"); els.forEach((tag: HTMLElement) => { diff --git a/packages/frontend/src/embed/scripts/parse-emoji.ts b/packages/frontend/src/embed/scripts/parse-emoji.ts new file mode 100644 index 0000000000..17fe6ab5d1 --- /dev/null +++ b/packages/frontend/src/embed/scripts/parse-emoji.ts @@ -0,0 +1,39 @@ +import { char2twemojiFilePath } from '@/scripts/emoji-base'; + +const char2path = char2twemojiFilePath; + +const remoteCustomEmojiEl = document.getElementById("remote_custom_emojis"); +let remoteCustomEmoji: { name: string; url: string; host?: string; }[] = []; +if (remoteCustomEmojiEl) { + remoteCustomEmoji = JSON.parse(remoteCustomEmojiEl.innerHTML); +} + +function getCustomEmojiName(ceNameRaw: string) { + return (ceNameRaw.startsWith(":") ? ceNameRaw.substr(1, ceNameRaw.length - 2) : ceNameRaw).replace('@.', '') +} + +function getCustomEmojiUrl(ceName: string) { + const remote = remoteCustomEmoji.find((e) => e.name === ceName); + if (remote) { + return remote.url; + } + return `/emoji/${ceName}.webp`; +} + +export function parseEmoji() { + document.querySelectorAll(".emoji").forEach((el) => { + let src: (string | null) = null; + if (el.innerHTML.startsWith(":")) { + console.log(getCustomEmojiName(el.innerHTML)); + src = getCustomEmojiUrl(getCustomEmojiName(el.innerHTML)); + } else { + src = char2path(el.innerHTML); + } + if (src) { + const emojiEl = document.createElement("img"); + emojiEl.src = src; + emojiEl.title = getCustomEmojiName(el.innerHTML); + el.innerHTML = emojiEl.outerHTML; + } + }); +} \ No newline at end of file diff --git a/packages/frontend/src/embed/scripts/parse-mfm.ts b/packages/frontend/src/embed/scripts/parse-mfm.ts index 0020b9787f..82165fb13f 100644 --- a/packages/frontend/src/embed/scripts/parse-mfm.ts +++ b/packages/frontend/src/embed/scripts/parse-mfm.ts @@ -1,6 +1,9 @@ import * as mfm from 'mfm-js'; import { toUnicode } from 'punycode'; import { host as localHost } from '@/config'; +import sanitizeHtml from 'sanitize-html'; +import Prism from 'prismjs'; +import 'prismjs/themes/prism-okaidia.css'; const QUOTE_STYLE = ` display: block; @@ -18,11 +21,16 @@ interface MfmFn extends mfm.MfmFn { } }; -export function parseMfm(text: string): HTMLDivElement { +export function parseMfm(text: string, useAnim: boolean = false): HTMLDivElement { const ast = mfm.parse(text); const el = document.createElement("div"); + const validTime = (t: string | null | undefined) => { + if (t == null) return null; + return t.match(/^[0-9.]+s$/) ? t : null; + }; + function genEl(ast: (MfmFn | mfm.MfmNode)[]) { return ast.map((token: (MfmFn | mfm.MfmNode)) => { switch (token.type) { @@ -69,6 +77,49 @@ export function parseMfm(text: string): HTMLDivElement { // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる let style; switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) ?? '1s'; + style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) ?? '1s'; + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : ''; + 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'; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; + break; + } case 'flip': { const transform = (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : @@ -122,6 +173,12 @@ export function parseMfm(text: string): HTMLDivElement { }) return [el]; } + case 'rainbow': { + const speed = validTime(token.props.args.speed) ?? '1s'; + style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; + break; + } + // TODO: Sparkle の プレーンHTML実装 case 'rotate': { const degrees = parseFloat(token.props.args.deg ?? '90'); style = `transform: rotate(${degrees}deg); transform-origin: center center;`; @@ -151,6 +208,9 @@ export function parseMfm(text: string): HTMLDivElement { style = `background-color: #${color};`; break; } + default: + style = ''; + break; } if (style == null) { const el = document.createElement("span"); @@ -240,14 +300,19 @@ export function parseMfm(text: string): HTMLDivElement { el.style.overflowX = 'scroll'; el.style.width = '100%'; const elc = document.createElement('code'); - elc.innerText = token.props.code; + const prismLang = Prism.languages[token.props.lang] ? token.props.lang : 'js'; + elc.innerHTML = Prism.highlight(token.props.code, Prism.languages[prismLang], prismLang); + el.classList.add(`language-${prismLang}`); + elc.classList.add(`language-${prismLang}`); el.appendChild(elc); return [el]; } case 'inlineCode': { const el = document.createElement('code'); - el.innerText = token.props.code; + const prismLang = 'js'; + el.innerHTML = Prism.highlight(token.props.code, Prism.languages[prismLang], prismLang); + el.classList.add(`language-${prismLang}`); return [el]; } @@ -262,14 +327,14 @@ export function parseMfm(text: string): HTMLDivElement { case 'emojiCode': { const el = document.createElement('span'); - el.classList.add('custom-emoji', 'needs-replacing'); + el.classList.add('emoji', 'custom-emoji'); el.innerText = `:${token.props.name}:`; return [el]; } case 'unicodeEmoji': { const el = document.createElement('span'); - el.classList.add('emoji'); + el.classList.add('emoji', 'unicode-emoji'); el.innerText = token.props.emoji; return [el]; } @@ -325,5 +390,11 @@ export function parseMfm(text: string): HTMLDivElement { el.appendChild(element as HTMLElement); }); + el.innerHTML = sanitizeHtml(el.innerHTML, { + allowedAttributes: { + '*': ['style', 'class'] + } + }); + return el; } \ No newline at end of file diff --git a/packages/frontend/src/embed/scripts/render-not-found.ts b/packages/frontend/src/embed/scripts/render-not-found.ts new file mode 100644 index 0000000000..7bd34a31a6 --- /dev/null +++ b/packages/frontend/src/embed/scripts/render-not-found.ts @@ -0,0 +1,22 @@ +import { instanceName } from "@/config"; +import { embedI18n as i18n } from '../scripts/embed-i18n'; + +export function renderNotFound() { + const el = document.getElementById("container"); + if (el) { + el.innerHTML = `
+
+ + +

${i18n.ts.notFound}

+

${i18n.ts.notFoundDescription}

+
+
`; + + } +} \ No newline at end of file diff --git a/packages/frontend/src/embed/scripts/theme.ts b/packages/frontend/src/embed/scripts/theme.ts new file mode 100644 index 0000000000..9920257d78 --- /dev/null +++ b/packages/frontend/src/embed/scripts/theme.ts @@ -0,0 +1,140 @@ +import tinycolor from 'tinycolor2'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record; +}; + +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { deepClone } from '@/scripts/clone'; +import { miLocalStorage } from '@/local-storage'; + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const getBuiltinThemes = () => Promise.all( + [ + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', + ].map(name => import(`../../themes/${name}.json5`).then(({ default: _default }): Theme => _default)), +); + +let timeout = 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 colorSchema = theme.base === 'dark' ? 'dark' : 'light'; + + // Deep copy + const _theme = deepClone(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-schema', colorSchema); + + if (persist) { + miLocalStorage.setItem('theme', JSON.stringify(props)); + miLocalStorage.setItem('colorSchema', colorSchema); + } + +} + +function compile(theme: Theme): Record { + function getColor(val: string): tinycolor.Instance { + // ref (prop) + if (val[0] === '@') { + return getColor(theme.props[val.substr(1)]); + } + + // ref (const) + else if (val[0] === '$') { + return getColor(theme.props[val]); + } + + // func + else if (val[0] === ':') { + const parts = val.split('<'); + const func = parts.shift().substr(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(); +} + +export function validateTheme(theme: Record): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +}