diff --git a/locales/en-US.yml b/locales/en-US.yml index a7ea78a72c..40e203ea72 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1193,6 +1193,10 @@ _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" + alwaysPlay: "Always autoplay all animated MFM" 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 3224f2da6c..01abb59763 100644 --- a/packages/client/src/components/MkSubNoteContent.vue +++ b/packages/client/src/components/MkSubNoteContent.vue @@ -33,7 +33,7 @@
+ + + +
@@ -128,14 +139,18 @@ 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"; +import { defaultStore } from "@/store"; const props = defineProps<{ note: misskey.entities.Note; @@ -164,6 +179,30 @@ 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 && defaultStore.state.animatedMfm); + +async function toggleMfm() { + if (disableMfm) { + if (!defaultStore.state.animatedMfmWarnShown) { + const { canceled } = await os.confirm({ + type: "warning", + text: i18n.ts._mfm.warn, + }); + if (canceled) return; + + defaultStore.set("animatedMfmWarnShown", true); + } + + disableMfm = false; + } else { + disableMfm = true; + } +} + function focusFooter(ev) { if (ev.key == "Tab" && !ev.getModifierState("Shift")) { emit("focusfooter"); @@ -195,6 +234,7 @@ function focusFooter(ev) { margin-right: 8px; } } + .wrmlmaau { .content { overflow-wrap: break-word; @@ -286,6 +326,13 @@ function focusFooter(ev) { } } } + + &.disableAnim :deep(span) { + animation: none !important; + } + } + > :deep(button) { + margin-top: 10px; } } diff --git a/packages/client/src/components/MkTutorialDialog.vue b/packages/client/src/components/MkTutorialDialog.vue index 8fecc44294..2d5dc45195 100644 --- a/packages/client/src/components/MkTutorialDialog.vue +++ b/packages/client/src/components/MkTutorialDialog.vue @@ -41,16 +41,26 @@ {{ i18n.ts.next }} -

- - {{ i18n.ts._tutorial.title }} -

-
+
+

+ + {{ i18n.ts._tutorial.title }} +

{{ i18n.ts._tutorial.step1_1 }}

{{ i18n.ts._tutorial.step1_2 }}
-
-
+ {{ i18n.ts._mfm.alwaysPlay }} + + + + {{ i18n.ts.reduceUiAnimation }} + + +

-
-
+
{{ i18n.ts.next }} -
-
+

-
-
+
-
-
+
{{ i18n.ts.pwa }} -
+
@@ -196,7 +206,7 @@ @@ -29,6 +29,7 @@ > .label { font-weight: bold; margin: 1.5em 0 16px 0; + font-size: 1em; &:empty { display: none; diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts index 3ded57e1a8..7e3c143357 100644 --- a/packages/client/src/components/mfm.ts +++ b/packages/client/src/components/mfm.ts @@ -102,35 +102,22 @@ export default defineComponent({ switch (token.props.name) { case "tada": { const speed = validTime(token.props.args.speed) || "1s"; - style = `font-size: 150%;${ - defaultStore.state.animatedMfm - ? `animation: tada ${speed} linear infinite both;` - : "" - }`; + style = `font-size: 150%; animation: tada ${speed} linear infinite both;`; break; } case "jelly": { const speed = validTime(token.props.args.speed) || "1s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: mfm-rubberBand ${speed} linear infinite both;` - : ""; + style = `animation: mfm-rubberBand ${speed} linear infinite both;`; break; } case "twitch": { const speed = validTime(token.props.args.speed) || "0.5s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: mfm-twitch ${speed} ease infinite;` - : ""; + style = `animation: mfm-twitch ${speed} ease infinite;`; break; } case "shake": { const speed = validTime(token.props.args.speed) || "0.5s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: mfm-shake ${speed} ease infinite;` - : ""; + style = `animation: mfm-shake ${speed} ease infinite;`; break; } case "spin": { @@ -145,38 +132,26 @@ export default defineComponent({ ? "mfm-spinY" : "mfm-spin"; const speed = validTime(token.props.args.speed) || "1.5s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` - : ""; + style = `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};`; break; } case "jump": { const speed = validTime(token.props.args.speed) || "0.75s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: mfm-jump ${speed} linear infinite;` - : ""; + style = `animation: mfm-jump ${speed} linear infinite;`; break; } case "bounce": { const speed = validTime(token.props.args.speed) || "0.75s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` - : ""; + style = `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;`; break; } case "rainbow": { const speed = validTime(token.props.args.speed) || "1s"; - style = - defaultStore.state.animatedMfm && !reducedMotion() - ? `animation: mfm-rainbow ${speed} linear infinite;` - : ""; + style = `animation: mfm-rainbow ${speed} linear infinite;`; break; } case "sparkle": { - if (!(defaultStore.state.animatedMfm || reducedMotion())) { + if (reducedMotion()) { return genEl(token.children); } return h(MkSparkle, {}, genEl(token.children)); diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue index b8cdb3fd9c..7bb7309b04 100644 --- a/packages/client/src/pages/settings/general.vue +++ b/packages/client/src/pages/settings/general.vue @@ -92,9 +92,13 @@ {{ i18n.ts.showAds }} - {{ - i18n.ts.disableAnimatedMfm - }} + + {{ i18n.ts._mfm.alwaysPlay }} + + {{ i18n.ts.reduceUiAnimation }} @@ -261,7 +265,7 @@ const showGapBetweenNotesInTimeline = computed( defaultStore.makeGetterSetter("showGapBetweenNotesInTimeline") ); const showAds = computed(defaultStore.makeGetterSetter("showAds")); -const disableAnimatedMfm = computed( +const autoplayMfm = computed( defaultStore.makeGetterSetter( "animatedMfm", (v) => !v, diff --git a/packages/client/src/scripts/extract-mfm.ts b/packages/client/src/scripts/extract-mfm.ts new file mode 100644 index 0000000000..88b1bb63f4 --- /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; +} diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index b4e10d2750..adebb1c497 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -158,6 +158,10 @@ export const defaultStore = markRaw( where: "device", default: true, }, + animatedMfmWarnShown: { + where: "device", + default: false, + }, loadRawImages: { where: "device", default: false,