diff --git a/.gitignore b/.gitignore index 479d57c4..c8beda86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ test/e2e/reports selenium-debug.log .idea/ config/local.json +config/local.*.json diff --git a/index.html b/index.html index d1fdd7d4..40db0bbe 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ <link rel="stylesheet" href="/static/font/css/animation.css"> <link rel="stylesheet" href="/static/font/tiresias.css"> <link rel="stylesheet" href="/static/font/css/lato.css"> + <link rel="stylesheet" href="/static/mfm.css"> <!--server-generated-meta--> <link rel="icon" type="image/png" href="/favicon.png"> </head> diff --git a/package.json b/package.json index 58aeb363..405b6fe4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "escape-html": "1.0.3", "js-cookie": "^3.0.1", "localforage": "1.10.0", + "mfm-js": "^0.22.1", "parse-link-header": "1.0.1", "phoenix": "1.6.2", "punycode.js": "2.1.0", diff --git a/src/components/mfm_content/mfm_content.jsx b/src/components/mfm_content/mfm_content.jsx new file mode 100644 index 00000000..e0ecda20 --- /dev/null +++ b/src/components/mfm_content/mfm_content.jsx @@ -0,0 +1,264 @@ +import { defineComponent, h } from 'vue' +import * as mfm from 'mfm-js' +import MentionLink from '../mention_link/mention_link.vue' +import mention_link from '../mention_link/mention_link' + +function concat (xss) { + return ([]).concat(...xss) +} + +export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate']; + + +export default defineComponent({ + props: { + status: { + type: Object, + required: true + } + }, + + render () { + if (!this.status) return null + const ast = mfm.parse(this.status.mfm_content, { fnNameList: MFM_TAGS }) + const validTime = (t) => { + if (t == null) return null + return t.match(/^[0-9.]+s$/) ? t : null + } + + const genEl = (ast) => concat(ast.map((token) => { + switch (token.type) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n') + + const res = [] + for (const t of text.split('\n')) { + res.push(h('br')) + res.push(t) + } + res.shift() + return res + } + + case 'bold': { + return [h('b', genEl(token.children))] + } + + case 'strike': { + return [h('del', genEl(token.children))] + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;' + }, genEl(token.children)) + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style + switch (token.props.name) { + case 'tada': { + style = `font-size: 150%;` + 'animation: tada 1s linear infinite both;' + break + } + case 'jelly': { + const speed = validTime(token.props.args.speed) || '1s' + style = `animation: mfm-rubberBand ${speed} linear infinite both;` + break + } + case 'twitch': { + const speed = validTime(token.props.args.speed) || '0.5s' + style = `animation: mfm-twitch ${speed} ease infinite;` + break + } + case 'shake': { + const speed = validTime(token.props.args.speed) || '0.5s' + style = `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 = `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` + break + } + case 'jump': { + style = 'animation: mfm-jump 0.75s linear infinite;' + break + } + case 'bounce': { + style = 'animation: mfm-bounce 0.75s linear infinite; transform-origin: center bottom;' + 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': { + style = `font-size: 200%;` + break + } + case 'x3': { + style = `font-size: 400%;` + break + } + case 'x4': { + style = `font-size: 600%;` + break + } + 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)) + } + case 'rainbow': { + style = 'animation: mfm-rainbow 1s linear infinite;' + break + } + case 'sparkle': { + return h(MkSparkle, {}, genEl(token.children)) + } + case 'rotate': { + const degrees = parseInt(token.props.args.deg) || '90' + style = `transform: rotate(${degrees}deg); transform-origin: center center;` + break + } + } + if (style == null) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']) + } else { + return h('span', { + style: 'display: inline-block;' + style + }, genEl(token.children)) + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;' + }, genEl(token.children))] + } + + case 'center': { + return [h('div', { + style: 'text-align:center;' + }, genEl(token.children))] + } + + case 'url': { + return [h('a', { + key: Math.random(), + href: token.props.url, + rel: 'nofollow noopener' + })] + } + + case 'link': { + return [h('a', { + key: Math.random(), + href: token.props.url, + rel: 'nofollow noopener' + }, genEl(token.children))] + } + + case 'mention': { + const user = this.status.attentions.find((mention) => `@${mention.screen_name}` === token.props.acct || mention.screen_name === token.props.username) + if (user) { + return [h(MentionLink, { + url: user.statusnet_profile_url, + content: token.props.acct, + userScreenName: token.props.acct + })] + } + return null + } + + case 'hashtag': { + return [h('a', { + rel: 'noopener noreferrer', + target: '_blank', + key: token.props.hashtag, + href: this.status.tags.find((hash) => hash.name === token.props.hashtag).url + }, `#${token.props.hashtag}`)] + } + + case 'blockCode': { + return [h('pre', { + key: Math.random(), + lang: token.props.lang + }, token.props.code)] + } + + case 'inlineCode': { + return [h('pre', { + key: Math.random(), + code: token.props.code, + inline: true + })] + } + + case 'quote': { + if (!this.nowrap) { + return [h('div', { + class: 'quote' + }, genEl(token.children))] + } else { + return [h('span', { + class: 'quote' + }, genEl(token.children))] + } + } + + case 'emojiCode': { + return [h('div', { + class: 'still-image emoji img' + }, + [h('img', { + key: Math.random(), + title: token.props.name, + alt: token.props.name, + src: this.status.emojis.find((emoji) => emoji.shortcode === token.props.name).static_url + })] + )] + } + + case 'unicodeEmoji': { + return token.props.emoji + } + + default: { + console.error('unrecognized ast type:', token.type) + + return [] + } + } + })) + + // Parse ast to DOM + return h('span', genEl(ast)) + } +}) diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 825ac06a..f2d332ce 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -99,6 +99,11 @@ {{ $t('settings.sensitive_if_subject') }} </BooleanSetting> </li> + <li> + <BooleanSetting path="renderMisskeyMarkdown"> + {{ $t('settings.render_mfm') }} + </BooleanSetting> + </li> <li> <BooleanSetting path="alwaysShowNewPostButton" diff --git a/src/components/status/status.scss b/src/components/status/status.scss index b3ad3818..7c8a9e51 100644 --- a/src/components/status/status.scss +++ b/src/components/status/status.scss @@ -5,6 +5,7 @@ white-space: normal; word-wrap: break-word; word-break: break-word; + overflow: hidden; &:hover { --_still-image-img-visibility: visible; diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js index b8f6f9a0..1fe9eb7b 100644 --- a/src/components/status_body/status_body.js +++ b/src/components/status_body/status_body.js @@ -1,5 +1,6 @@ import fileType from 'src/services/file_type/file_type.service' import RichContent from 'src/components/rich_content/rich_content.jsx' +import MFMContent from 'src/components/mfm_content/mfm_content.jsx' import { mapGetters } from 'vuex' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -35,9 +36,11 @@ const StatusContent = { 'toggleShowingLongSubject' ], data () { + const { renderMisskeyMarkdown } = this.$store.getters.mergedConfig return { postLength: this.status.text.length, - parseReadyDone: false + parseReadyDone: false, + renderMisskeyMarkdown } }, computed: { @@ -81,7 +84,8 @@ const StatusContent = { ...mapGetters(['mergedConfig']) }, components: { - RichContent + RichContent, + MFMContent }, mounted () { this.status.attentions && this.status.attentions.forEach(attn => { diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue index 976fe98c..7add83aa 100644 --- a/src/components/status_body/status_body.vue +++ b/src/components/status_body/status_body.vue @@ -41,18 +41,26 @@ > {{ $t("general.show_more") }} </button> - <RichContent + <div v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)" - :class="{ '-single-line': singleLine }" - class="text media-body" - :html="status.raw_html" - :emoji="status.emojis" - :handle-links="true" - :greentext="mergedConfig.greentext" - :attentions="status.attentions" - @parseReady="onParseReady" - /> - + > + <MFMContent + v-if="renderMisskeyMarkdown && status.mfm_content" + class="RichContent text media-body mfm-post-content" + :status="status" + /> + <RichContent + v-else + :class="{ '-single-line': singleLine }" + class="text media-body" + :html="status.raw_html" + :emoji="status.emojis" + :handle-links="true" + :greentext="mergedConfig.greentext" + :attentions="status.attentions" + @parseReady="onParseReady" + /> + </div> <button v-show="hideSubjectStatus" class="button-unstyled -link cw-status-hider" diff --git a/src/i18n/en.json b/src/i18n/en.json index 17977bdc..ec2882c5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -209,7 +209,8 @@ "text/plain": "Plain text", "text/html": "HTML", "text/markdown": "Markdown", - "text/bbcode": "BBCode" + "text/bbcode": "BBCode", + "text/x.misskeymarkdown": "MFM" }, "content_warning": "Subject (optional)", "default": "Just landed in L.A.", @@ -507,6 +508,7 @@ "post_status_content_type": "Post status content type", "sensitive_by_default": "Mark posts as sensitive by default", "sensitive_if_subject": "Automatically mark images as sensitive if a subject line is specified", + "render_mfm": "Render Misskey Markdown", "useStreamingApiWarning": "It's cool use it. If it breaks refresh I guess?", "stop_gifs": "Pause animated images until you hover on them", "streaming": "Automatically show new posts when scrolled to the top", @@ -840,7 +842,7 @@ "hide_repeats": "Hide repeats", "show_repeats": "Show repeats", "domain_muted": "Unblock domain", - "mute_domain": "Block domain", + "mute_domain": "Block domain", "bot": "Bot", "admin_menu": { "moderation": "Moderation", diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json index 259b5480..cb52dffa 100644 --- a/src/i18n/ja_pedantic.json +++ b/src/i18n/ja_pedantic.json @@ -582,6 +582,7 @@ "greentext": "引用を緑色で表示", "sensitive_by_default": "はじめから投稿をセンシティブとして設定", "sensitive_if_subject": "ステータスにサブジェクトをついたらNSFWにする", + "render_mfm": "Misskey Markdownを表示", "more_settings": "その他の設定", "reply_visibility_self_short": "自分宛のリプライを見る", "reply_visibility_following_short": "フォローしている人に宛てられたリプライを見る", diff --git a/src/modules/config.js b/src/modules/config.js index 4c860414..3afeeebe 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -95,6 +95,7 @@ export const defaultState = { virtualScrolling: undefined, // instance default sensitiveByDefault: undefined, // instance default sensitiveIfSubject: undefined, + renderMisskeyMarkdown: undefined, conversationDisplay: undefined, // instance default conversationTreeAdvanced: undefined, // instance default conversationOtherRepliesButton: undefined, // instance default diff --git a/src/modules/instance.js b/src/modules/instance.js index 50ede1bb..1da697a0 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -55,6 +55,7 @@ const defaultState = { virtualScrolling: true, sensitiveByDefault: false, sensitiveIfSubject: false, + renderMisskeyMarkdown: false, conversationDisplay: 'linear', conversationTreeAdvanced: false, conversationOtherRepliesButton: 'below', diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 9d0285b9..7924815e 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -280,6 +280,15 @@ export const parseStatus = (data) => { output.summary = data.spoiler_text } + if (data.akkoma) { + const { akkoma } = data + if (akkoma && akkoma.source && akkoma.source.mediaType === 'text/x.misskeymarkdown') { + output.mfm_content = akkoma.source.content + } + } else { + output.mfm_content = null + } + output.in_reply_to_status_id = data.in_reply_to_id output.in_reply_to_user_id = data.in_reply_to_account_id output.replies_count = data.replies_count diff --git a/static/mfm.css b/static/mfm.css new file mode 100644 index 00000000..3be59f7e --- /dev/null +++ b/static/mfm.css @@ -0,0 +1,143 @@ +.mfm-post-content { + overflow-y: hidden; +}; + +@keyframes tada { + from { + transform: scale3d(1, 1, 1); + } + + 10%, + 20% { + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + } + + 30%, + 50%, + 70%, + 90% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + } + + 40%, + 60%, + 80% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + } + + to { + transform: scale3d(1, 1, 1); + } +} + +@keyframes bounce { + 0% { + transform: scaleX(0.9) scaleY(0.9); + } + + 19% { + transform: scaleX(1.1) scaleY(1.1); + } + + 48% { + transform: scaleX(0.95) scaleY(0.95); + } + + 100% { + transform: scaleX(1) scaleY(1); + } +} + +@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); } +} + +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px); } + 5% { transform: translate(-3px, 1px); } + 10% { transform: translate(-7px, -1px); } + 15% { transform: translate(0, -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, 0); } + 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); } +} + +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg); } + 5% { transform: translate(0, -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(0, -1px) rotate(-12deg); } + 50% { transform: translate(1px, 2px) rotate(10deg); } + 55% { transform: translate(0, -3px) rotate(8deg); } + 60% { transform: translate(1px, -1px) rotate(8deg); } + 65% { transform: translate(0, -1px) rotate(-7deg); } + 70% { transform: translate(-1px, -3px) rotate(6deg); } + 75% { transform: translate(0, -2px) rotate(4deg); } + 80% { transform: translate(-2px, -1px) rotate(3deg); } + 85% { transform: translate(1px, -3px) rotate(-10deg); } + 90% { transform: translate(1px, 0) rotate(3deg); } + 95% { transform: translate(-2px, 0) 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/yarn.lock b/yarn.lock index e413b6d1..4c905d44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6559,6 +6559,13 @@ methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" +mfm-js@^0.22.1: + version "0.22.1" + resolved "https://registry.yarnpkg.com/mfm-js/-/mfm-js-0.22.1.tgz#ad5f0b95cc903ca5a5e414e2edf64ac4648dc8c2" + integrity sha512-UV5zvDKlWPpBFeABhyCzuOTJ3RwrNrmVpJ+zz/dFX6D/ntEywljgxkfsLamcy0ZSwUAr0O+WQxGHvAwyxUgsAQ== + dependencies: + twemoji-parser "14.0.x" + micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" @@ -9647,6 +9654,11 @@ tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" +twemoji-parser@14.0.x: + version "14.0.0" + resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-14.0.0.tgz#13dabcb6d3a261d9efbf58a1666b182033bf2b62" + integrity sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA== + type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"