diff --git a/changelog.d/mfm.add b/changelog.d/mfm.add
new file mode 100644
index 000000000..b5d320955
--- /dev/null
+++ b/changelog.d/mfm.add
@@ -0,0 +1 @@
+Initial MFM rendering support
diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index ae1f1c9c6..27e275bfb 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -36,6 +36,12 @@ const BasicUserCard = {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
+ pauseMfm() {
+ return useMergedConfigStore().mergedConfig.pauseMfm
+ },
+ scaleMfm() {
+ return useMergedConfigStore().mergedConfig.scaleMfm
+ },
},
}
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index 49786bf1b..39b269ea8 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -32,6 +32,8 @@
:html="user.name"
:emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
/>
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index 1d12384cc..e552ce9fd 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -21,5 +21,11 @@ export default {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
+ pauseMfm() {
+ return useMergedConfigStore().mergedConfig.pauseMfm
+ },
+ scaleMfm() {
+ return useMergedConfigStore().mergedConfig.scaleMfm
+ },
},
}
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index 313e66ce3..764e0de61 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -20,6 +20,8 @@
:html="htmlTitle"
:emoji="user.emoji || []"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
:is-local="user.is_local"
/>
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index b6ee27f9c..1bfe540a0 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -217,6 +217,12 @@ const Notification = {
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
+ pauseMfm() {
+ return this.mergedConfig.pauseMfm
+ },
+ scaleMfm() {
+ return this.mergedConfig.scaleMfm
+ },
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 381aef788..1c1191940 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -72,6 +72,8 @@
:html="notification.from_profile.name_html"
:emoji="notification.from_profile.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
:is-local="notification.from_profile.is_local"
/>
diff --git a/src/components/palette_editor/palette_editor.vue b/src/components/palette_editor/palette_editor.vue
index 1a73d5fdb..508b83f87 100644
--- a/src/components/palette_editor/palette_editor.vue
+++ b/src/components/palette_editor/palette_editor.vue
@@ -20,7 +20,9 @@
:show-ratio="true"
:contrast="contrast[key]"
/>
-
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index 3ea9a1890..b93a6699d 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -57,6 +57,12 @@ export default {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
+ pauseMfm() {
+ return useMergedConfigStore().mergedConfig.pauseMfm
+ },
+ scaleMfm() {
+ return useMergedConfigStore().mergedConfig.scaleMfm
+ },
loggedIn() {
return this.$store.state.users.currentUser
},
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 9ac859824..89bb1324e 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -25,6 +25,8 @@
:handle-links="false"
:emoji="emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
/>
{newChildren}
+ const attrs = getAttrs(opener)
+ const newAttrs = { ...attrs }
+ const fullAttrs = getAttrs(opener, () => true)
+ const classname = fullAttrs['class']
+ const isMFM = classname?.startsWith('mfm-')
+ if (isMFM) {
+ const mfmOperator = /^mfm-(\w+)$/.exec(classname)?.[1]
+ newAttrs['class'] = [
+ 'mfm',
+ this.pauseMfm ? '-pause' : '',
+ this.scaleMfm ? '-scale' : '',
+ ]
+ .filter((x) => x)
+ .join(' ')
+ newAttrs['data-mfm-operator'] = mfmOperator
+ switch (mfmOperator) {
+ case 'position': {
+ const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 0
+ const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 0
+ newAttrs.style = [
+ 'transform:',
+ `translate(calc(${x} * (var(--emoji-size) / 2)), `,
+ `calc(${y} * (var(--emoji-size) / 2)))`,
+ ].join(' ')
+ break
+ }
+ case 'scale': {
+ const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 1
+ const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 1
+ newAttrs.style = ['transform:', `scale(${x}, ${y})`].join(' ')
+ break
+ }
+ case 'rotate': {
+ const deg = Number.parseFloat(fullAttrs['data-mfm-deg']) || 0
+ newAttrs.style = [
+ `transform: rotate(${deg}deg)`,
+ 'transform-origin: center',
+ ].join(';')
+ break
+ }
+ case 'bg': {
+ const color = fullAttrs['data-mfm-color'] || 0
+ newAttrs.style = [`background-color: #${color}`].join(' ')
+ break
+ }
+ case 'fg': {
+ const color = fullAttrs['data-mfm-color'] || 0
+ newAttrs.style = [`color: #${color}`].join(';')
+ break
+ }
+ case 'spin': {
+ const speed = fullAttrs['data-mfm-speed'] || '1s'
+ const delay = fullAttrs['data-mfm-delay'] || 0
+ const left = fullAttrs['data-mfm-left'] != null
+ const alternate = fullAttrs['data-mfm-alternate'] != null
+ const y = fullAttrs['data-mfm-y'] != null
+ const x = fullAttrs['data-mfm-x'] != null
+
+ const anim = [
+ x ? 'mfm-spinX' : null,
+ y ? 'mfm-spinY' : null,
+ 'mfm-spin',
+ ].filter((a) => a)[0]
+
+ const direction = [
+ alternate ? 'alternate' : null,
+ left ? 'reverse' : null,
+ 'normal',
+ ].filter((a) => a)[0]
+
+ newAttrs.style = [
+ `animation-name: ${anim}`,
+ `animation-duration: ${speed}`,
+ 'animation-iteration-count: infinite',
+ `animation-delay: ${delay}`,
+ `animation-direction: ${direction}`,
+ 'animation-fill-mode: none',
+ 'animation-timing-function: linear',
+ ].join(';')
+ break
+ }
+ case 'flip': {
+ newAttrs.style = 'transform: scaleX(-1)'
+ break
+ }
+ case 'border': {
+ const width = fullAttrs['data-mfm-width'] || '0'
+ const style = fullAttrs['data-mfm-style'] || 'solid'
+ const color = fullAttrs['data-mfm-color'] || 'transparent'
+ const radius = fullAttrs['data-mfm-radius'] || '0'
+ const noclip = fullAttrs['data-mfm-noclip'] || false
+
+ newAttrs.style = [
+ `border: ${width} ${style} ${color}`,
+ `border-radius: ${radius}`,
+ `overflow: ${noclip ? 'visible' : 'clip'}`,
+ ].join(';')
+ break
+ }
+ case 'tada':
+ case 'jelly':
+ case 'twitch':
+ case 'shake':
+ case 'jump':
+ case 'bounce':
+ case 'rainbow': {
+ const speed = fullAttrs['data-mfm-speed'] || '1s'
+ const delay = fullAttrs['data-mfm-delay'] || 0
+
+ const rules = [
+ `animation-name: mfm-${mfmOperator}`,
+ `animation-duration: ${speed}`,
+ 'animation-iteration-count: infinite',
+ `animation-delay: ${delay}`,
+ 'animation-direction: normal',
+ 'animation-fill-mode: none',
+ 'animation-timing-function: linear',
+ ].join(';')
+ newAttrs.style = rules
+ break
+ }
+ case 'sparkle':
+ case 'x2':
+ case 'x3':
+ case 'x4':
+ // handled by css
+ break
+ default:
+ console.warn('Unsupported MFM operator:', mfmOperator, opener)
+ break
+ }
+ }
+ return
{newChildren}
} else {
return
}
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index 33effc623..aa8845be4 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -100,9 +100,371 @@
.cyantext {
color: var(--funtextCyantext);
}
+
+ .mfm {
+ display: inline-block;
+
+ &.-scale {
+ font-size: calc(var(--emoji-size) / 2);
+ }
+
+ &:not(.-scale) {
+ --emoji-size: 2em;
+ }
+
+ &.-pause {
+ animation-play-state: paused;
+ }
+
+ &[data-mfm-operator="x2"] {
+ font-size: 300%
+ }
+
+ &[data-mfm-operator="x3"] {
+ font-size: 400%
+ }
+
+ &[data-mfm-operator="x4"] {
+ font-size: 600%
+ }
+
+ &[data-mfm-operator="sparkle"] {
+ position: relative;
+
+ &::before,
+ &::after {
+ content: '✨';
+ position: absolute;
+ opacity: 0.4;
+ z-index: -1;
+ animation-name: cheap-sparkle;
+ animation-duration: 1s;
+ animation-timing-function: ease-in-out;
+ animation-iteration-count: infinite;
+ }
+
+ &::before {
+ left: 0;
+ animation-direction: alternate;
+ }
+
+ &::after {
+ right: 0;
+ animation-direction: alternate-reverse;
+ }
+ }
+
+ .emoji {
+ /* Misskey's emoji width knows no bounds */
+ /* stylelint-disable-next-line declaration-no-important */
+ max-width: unset !important;
+ }
+ }
+
+ &:hover .mfm {
+ animation-play-state: running;
+ }
}
a .RichContent {
/* stylelint-disable-next-line declaration-no-important */
color: var(--link) !important;
}
+
+@keyframes mfm-spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes mfm-spinX {
+ 0% {
+ transform: perspective(8em) rotateX(0);
+ }
+
+ 100% {
+ transform: perspective(8em) rotateX(360deg);
+ }
+}
+
+@keyframes mfm-spinY {
+ 0% {
+ transform: perspective(8em) rotateY(0);
+ }
+
+ 100% {
+ transform: perspective(8em) rotateY(360deg);
+ }
+}
+
+@keyframes mfm-jump {
+ 0% {
+ transform: translateY(0);
+ }
+
+ 25% {
+ transform: translateY(-1em);
+ }
+
+ 50% {
+ transform: translateY(0);
+ }
+
+ 75% {
+ transform: translateY(-0.5em);
+ }
+
+ 100% {
+ transform: translateY(0);
+ }
+}
+
+@keyframes mfm-bounce {
+ 0% {
+ transform: translateY(0) scale(1);
+ }
+
+ 25% {
+ transform: translateY(-16px) scale(1);
+ }
+
+ 50% {
+ transform: translateY(0) scale(1);
+ }
+
+ 75% {
+ transform: translateY(0) scale(1.5,.75);
+ }
+
+ 100% {
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes mfm-twitch {
+ 0% {
+ transform: translate(7px, -2px);
+ }
+
+ 5% {
+ transform: translate(-3px, 1px);
+ }
+
+ 10% {
+ transform: translate(-7px, -1px);
+ }
+
+ 15% {
+ transform: translateY(-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);
+ }
+
+ 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: translateY(-1px) rotate(-10deg);
+ }
+
+ 10% {
+ transform: translate(1px, -3px) rotate(0);
+ }
+
+ 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: translateY(-1px) rotate(-12deg);
+ }
+
+ 50% {
+ transform: translate(1px, 2px) rotate(10deg);
+ }
+
+ 55% {
+ transform: translateY(-3px) rotate(8deg);
+ }
+
+ 60% {
+ transform: translate(1px, -1px) rotate(8deg);
+ }
+
+ 65% {
+ transform: translateY(-1px) rotate(-7deg);
+ }
+
+ 70% {
+ transform: translate(-1px, -3px) rotate(6deg);
+ }
+
+ 75% {
+ transform: translateY(-2px) rotate(4deg);
+ }
+
+ 80% {
+ transform: translate(-2px, -1px) rotate(3deg);
+ }
+
+ 85% {
+ transform: translate(1px, -3px) rotate(-10deg);
+ }
+
+ 90% {
+ transform: translate(1px) rotate(3deg);
+ }
+
+ 95% {
+ transform: translate(-2px) rotate(-3deg);
+ }
+
+ 100% {
+ transform: translate(2px, 1px) rotate(2deg);
+ }
+}
+
+@keyframes mfm-rubberBand {
+ 0% {
+ transform: scale(1);
+ }
+
+ 30% {
+ transform: scale(1.25, .75);
+ }
+
+ 40% {
+ transform: scale(.75, 1.25);
+ }
+
+ 50% {
+ transform: scale(1.15, .85);
+ }
+
+ 65% {
+ transform: scale(.95, 1.05);
+ }
+
+ 75% {
+ transform: scale(1.05, .95);
+ }
+
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes mfm-rainbow {
+ 0% {
+ filter: hue-rotate() contrast(150%) saturate(150%);
+ }
+
+ 100% {
+ filter: hue-rotate(360deg) contrast(150%) saturate(150%);
+ }
+}
+
+@keyframes cheap-sparkle {
+ 0% {
+ filter: hue-rotate() contrast(150%) saturate(150%);
+ transform: translateY(-0.5em);
+ }
+
+ 100% {
+ filter: hue-rotate(360deg) contrast(150%) saturate(150%);
+ transform: translateY(0.5em);
+ }
+}
diff --git a/src/components/settings_modal/tabs/posts_tab.vue b/src/components/settings_modal/tabs/posts_tab.vue
index fc33d5476..cbba67f12 100644
--- a/src/components/settings_modal/tabs/posts_tab.vue
+++ b/src/components/settings_modal/tabs/posts_tab.vue
@@ -174,6 +174,16 @@
{{ $t('settings.non_square_emoji') }}
+
+
+ {{ $t('settings.scale_mfm') }}
+
+
+
+
+ {{ $t('settings.pause_mfm') }}
+
+
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 7a46a1a3b..e6752cb1a 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -59,6 +59,12 @@ const StatusBody = {
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
+ pauseMfm() {
+ return this.mergedConfig.pauseMfm
+ },
+ scaleMfm() {
+ return this.mergedConfig.scaleMfm
+ },
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses for paragraphs, GS uses between them)
// as well as approximate line count by counting characters and approximating ~80
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index b611d69b8..e8994180a 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -16,6 +16,8 @@
:emoji="status.emojis"
:is-local="status.isLocal"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
/>
@@ -542,6 +546,8 @@
:html="field.name"
:emoji="editable ? emoji : user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
/>
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
index 7f9ba4ffb..84107d6b1 100644
--- a/src/components/user_list_popover/user_list_popover.js
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -27,6 +27,12 @@ const UserListPopover = {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
+ pauseMfm() {
+ return useMergedConfigStore().mergedConfig.pauseMfm
+ },
+ scaleMfm() {
+ return useMergedConfigStore().mergedConfig.scaleMfm
+ },
},
methods: {
generateProfileLink(user) {
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
index 02fa5a2d2..ddeb6f6f6 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -29,6 +29,8 @@
:html="user.name_html"
:emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
+ :pause-mfm="pauseMfm"
+ :scale-mfm="scaleMfm"
/>
{{ user.screen_name_ui }}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 816c20a85..644e082a1 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -807,6 +807,8 @@
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"non_square_emoji": "Allow non-square emoji",
+ "pause_mfm": "Pause MFM animations until you hover on them",
+ "scale_mfm": "Scale MFM animations with emoji size",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
diff --git a/src/modules/default_config_state.js b/src/modules/default_config_state.js
index ec190af28..d9a2ba247 100644
--- a/src/modules/default_config_state.js
+++ b/src/modules/default_config_state.js
@@ -237,6 +237,14 @@ export const INSTANCE_DEFAULT_CONFIG_DEFINITIONS = {
description: 'Allow emoji to be non-square (max 3:1 aspect)',
default: true,
},
+ pauseMfm: {
+ description: 'Pause MFM animations',
+ default: true,
+ },
+ scaleMfm: {
+ description: 'Scale MFM animation with emoji size',
+ default: false,
+ },
replyVisibility: {
description: 'Type of replies to show',
default: 'all',