diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 0148e0c27e82829e65890d78b73995290882d757..e34c7b07cbd3f60fb3f3921075ea556623d2c868 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -70,6 +70,7 @@ SPDX-License-Identifier: AGPL-3.0-only :emojiUrls="appearNote.emojis" :enableEmojiMenu="true" :enableEmojiMenuReaction="true" + :isAnim="allowAnim" /> <div v-if="translating || translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> @@ -78,6 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/> </div> </div> + <MkButton v-if="!allowAnim && animated" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> Animated MFM</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> Animated MFM</MkButton> </div> <div v-if="appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files" v-on:click.stop/> @@ -175,6 +178,7 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import MkButton from '@/components/MkButton.vue'; import { pleaseLogin } from '@/scripts/please-login.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; @@ -183,9 +187,10 @@ import * as os from '@/os.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; +import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; @@ -261,6 +266,8 @@ const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); const urls = parsed ? extractUrlFromMfm(parsed) : null; +const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const isLong = shouldCollapsed(appearNote, urls ?? []); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); @@ -700,6 +707,18 @@ function showRenoteMenu(viaKeyboard = false): void { } } +function animatedMFM() { + if (allowAnim.value) { + allowAnim.value = false; + } else { + os.confirm({ + type: 'warning', + text: 'Animated MFMs could include flashing lights and fast moving text/emojis.', + okText: 'Animate', + }).then((res) => { if (!res.canceled) allowAnim.value = true; }); + } +} + function focus() { el.value.focus(); } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index bb8a0aa7366b64edc02213a98130d69719aadea7..ecc2187903f244bd06d75b564db8a5710067624c 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -83,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only :emojiUrls="appearNote.emojis" :enableEmojiMenu="true" :enableEmojiMenuReaction="true" + :isAnim="allowAnim" /> <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> <div v-if="translating || translation" :class="$style.translation"> @@ -92,6 +93,8 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'account'" :emojiUrls="appearNote.emojis"/> </div> </div> + <MkButton v-if="!allowAnim && animated" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> Animated MFM</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> Animated MFM</MkButton> <div v-if="appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> @@ -247,6 +250,7 @@ import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { MenuItem } from '@/types/menu.js'; +import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -306,6 +310,8 @@ const translation = ref(null); const translating = ref(false); const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); const urls = parsed ? extractUrlFromMfm(parsed) : null; +const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); @@ -734,6 +740,18 @@ function loadConversation() { } if (appearNote.reply && appearNote.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); + +function animatedMFM() { + if (allowAnim.value) { + allowAnim.value = false; + } else { + os.confirm({ + type: 'warning', + text: 'Animated MFMs could include flashing lights and fast moving text/emojis.', + okText: 'Animate', + }).then((res) => { if (!res.canceled) allowAnim.value = true; }); + } +} </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index ac07e994ece026581d8c95e5fcca26903d446108..0cedcb4ac57ca20623dca005933179e0840d311b 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -9,7 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" v-on:click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'account'" :emojiUrls="note.emojis"/> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'account'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> + <MkButton v-if="!allowAnim && animated && !hideFiles" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> Animated MFM</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated && !hideFiles" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> Animated MFM</MkButton> <div v-if="note.text && translating || note.text && translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else> @@ -39,13 +41,17 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; import * as Misskey from 'misskey-js'; +import * as mfm from 'mfm-js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; +import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { defaultStore } from '@/store.js'; import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -60,8 +66,24 @@ function noteclick(id: string) { router.push(`/notes/${id}`); } +const parsed = $computed(() => props.note.text ? mfm.parse(props.note.text) : null); +const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +let allowAnim = $ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); + const isLong = shouldCollapsed(props.note, []); +function animatedMFM() { + if (allowAnim) { + allowAnim = false; + } else { + os.confirm({ + type: 'warning', + text: 'Animated MFMs could include flashing lights and fast moving text/emojis.', + okText: 'Animate', + }).then((res) => { if (!res.canceled) allowAnim = true; }); + } +} + const collapsed = $ref(isLong); </script> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index 5ff20fbe9bebf773a12699ea64ad937e44f142ad..9208285e990e5230a65468168422ff639dea8de5 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -40,6 +40,7 @@ type MfmProps = { parsedNodes?: mfm.MfmNode[] | null; enableEmojiMenu?: boolean; enableEmojiMenuReaction?: boolean; + isAnim?: boolean; }; // eslint-disable-next-line import/no-default-export @@ -57,7 +58,7 @@ export default function(props: MfmProps) { return t.match(/^[0-9.]+s$/) ? t : null; }; - const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm; + const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : props.isAnim ? true : false; /** * Gen Vue Elements from MFM AST diff --git a/packages/frontend/src/scripts/check-animated-mfm.ts b/packages/frontend/src/scripts/check-animated-mfm.ts new file mode 100644 index 0000000000000000000000000000000000000000..f47c40d4bc6ea46a825ef7741ac58789b77c0091 --- /dev/null +++ b/packages/frontend/src/scripts/check-animated-mfm.ts @@ -0,0 +1,29 @@ +import * as mfm from 'mfm-js'; + +export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean { + const animatedNodes = mfm.extract(nodes, (node) => { + if (node.type === 'fn') { + if (node.props.name === 'tada' || + node.props.name === 'jelly' || + node.props.name === 'twitch' || + node.props.name === 'shake' || + node.props.name === 'spin' || + node.props.name === 'jump' || + node.props.name === 'bounce' || + node.props.name === 'rainbow' || + node.props.name === 'sparkle') { + return true; + } else { + return false; + } + } else { + return false; + } + }); + + if (animatedNodes.length > 0) { + return true; + } else { + return false; + } +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 56fa958f08e2711bc72edb32ba5b6306849458eb..a4f8f4d1102bd7f6338942f08f1926171501a904 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -200,7 +200,7 @@ export const defaultStore = markRaw(new Storage('base', { }, animatedMfm: { where: 'device', - default: true, + default: false, }, advancedMfm: { where: 'device',