diff --git a/locales/en-US.yml b/locales/en-US.yml
index bd32c836a..4c73c5b8f 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1193,6 +1193,9 @@ _nsfw:
ignore: "Don't hide NSFW media"
force: "Hide all media"
_mfm:
+ play: "Play MFM"
+ stop: "Stop MFM"
+ warn: "MFM may contain rapidly moving or flashy animations"
cheatSheet: "MFM Cheatsheet"
intro: "MFM is a markup language used on Misskey, Calckey, Akkoma, and more that\
\ can be used in many places. Here you can view a list of all available MFM syntax."
diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/MkSubNoteContent.vue
index 3224f2da6..773f7f26e 100644
--- a/packages/client/src/components/MkSubNoteContent.vue
+++ b/packages/client/src/components/MkSubNoteContent.vue
@@ -33,7 +33,7 @@
+
+
+ {{ i18n.ts._mfm.play }}
+
+
+ {{ i18n.ts._mfm.stop }}
+
+
@@ -128,13 +139,16 @@
import { ref } from "vue";
import * as misskey from "calckey-js";
import * as mfm from "mfm-js";
+import * as os from "@/os";
import XNoteSimple from "@/components/MkNoteSimple.vue";
import XMediaList from "@/components/MkMediaList.vue";
import XPoll from "@/components/MkPoll.vue";
import MkUrlPreview from "@/components/MkUrlPreview.vue";
import XShowMoreButton from "@/components/MkShowMoreButton.vue";
import XCwButton from "@/components/MkCwButton.vue";
+import MkButton from "@/components/MkButton.vue";
import { extractUrlFromMfm } from "@/scripts/extract-url-from-mfm";
+import { extractMfmWithAnimation } from "@/scripts/extract-mfm";
import { i18n } from "@/i18n";
const props = defineProps<{
@@ -164,6 +178,26 @@ const urls = props.note.text
let showContent = $ref(false);
+const mfms = props.note.text ? extractMfmWithAnimation(mfm.parse(props.note.text)) : null;
+
+const hasMfm = $ref(mfms.length > 0);
+
+let disableMfm = $ref(hasMfm);
+
+async function toggleMfm() {
+ if (disableMfm) {
+ const { canceled } = await os.confirm({
+ type: "warning",
+ text: i18n.ts._mfm.warn,
+ });
+ if (canceled) return;
+
+ disableMfm = false;
+ } else {
+ disableMfm = true;
+ }
+}
+
function focusFooter(ev) {
if (ev.key == "Tab" && !ev.getModifierState("Shift")) {
emit("focusfooter");
@@ -195,6 +229,12 @@ function focusFooter(ev) {
margin-right: 8px;
}
}
+
+.mfm-warning {
+ button {
+ padding: 1em;
+ }
+}
.wrmlmaau {
.content {
overflow-wrap: break-word;
@@ -286,6 +326,11 @@ function focusFooter(ev) {
}
}
}
+
+ &.disableAnim :deep(*) {
+ animation: none !important;
+ transition: none !important;
+ }
}
}
diff --git a/packages/client/src/scripts/extract-mfm.ts b/packages/client/src/scripts/extract-mfm.ts
new file mode 100644
index 000000000..88b1bb63f
--- /dev/null
+++ b/packages/client/src/scripts/extract-mfm.ts
@@ -0,0 +1,16 @@
+import * as mfm from "mfm-js";
+
+const animatedMfm = ["tada", "jelly", "twitch", "shake", "spin", "jump", "bounce", "rainbow"];
+
+export function extractMfmWithAnimation(
+ nodes: mfm.MfmNode[],
+): string[] {
+ const mfmNodes = mfm.extract(nodes, (node) => {
+ return (
+ node.type === "fn" && animatedMfm.indexOf(node.props.name) > -1
+ );
+ });
+ const mfms = mfmNodes.map((x) => x.props.fn);
+
+ return mfms;
+}