summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authortaichan <40626578+tai-cha@users.noreply.github.com>2025-05-12 10:00:06 +0900
committerGitHub <noreply@github.com>2025-05-12 10:00:06 +0900
commit5bc52b6743d1d23d215aaa7b62ceee85f15c3502 (patch)
tree0426e5a239f65cadf8ad7a8f3a14dba6c7a68cd3 /packages
parentlint (diff)
downloadmisskey-5bc52b6743d1d23d215aaa7b62ceee85f15c3502.tar.gz
misskey-5bc52b6743d1d23d215aaa7b62ceee85f15c3502.tar.bz2
misskey-5bc52b6743d1d23d215aaa7b62ceee85f15c3502.zip
feat(frontend): 絵文字をミュート可能にする機能 (#15966)
* wip ( 絵文字ミュートの基礎実装, PoC ) * refactor: 絵文字のmute/unmute処理の共通化 * SPDX * リアクションからも絵文字ミュート可能に * emojiMute/emojiUnmute * replace resource of emojiMute * add vitest preferstate for mutedEmojis * add vitest to preferReactive * 混入削除 * Fix typo (mutedEmojis -> mutingEmojis) * reactiveやめる * add時の判定ミスを修正 * Add CHANGELOG * Revert "reactiveやめる" This reverts commit 442742c371472f9c9e9372c5552cf73767aedecf. * Update Changelog
Diffstat (limited to 'packages')
-rw-r--r--packages/frontend/assets/unknown.pngbin0 -> 10770 bytes
-rw-r--r--packages/frontend/src/components/MkReactionsViewer.reaction.vue65
-rw-r--r--packages/frontend/src/components/global/MkCustomEmoji.vue112
-rw-r--r--packages/frontend/src/components/global/MkEmoji.vue47
-rw-r--r--packages/frontend/src/components/global/MkMfm.ts2
-rw-r--r--packages/frontend/src/pages/settings/mute-block.emoji-mute.vue105
-rw-r--r--packages/frontend/src/pages/settings/mute-block.vue15
-rw-r--r--packages/frontend/src/preferences/def.ts3
-rw-r--r--packages/frontend/src/utility/emoji-mute.ts61
-rw-r--r--packages/frontend/test/init.ts13
10 files changed, 387 insertions, 36 deletions
diff --git a/packages/frontend/assets/unknown.png b/packages/frontend/assets/unknown.png
new file mode 100644
index 0000000000..d27bdfc8b3
--- /dev/null
+++ b/packages/frontend/assets/unknown.png
Binary files differ
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index 9fc773b335..7d76dffa5a 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -22,6 +22,7 @@ import { computed, inject, onMounted, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { getUnicodeEmoji } from '@@/js/emojilist.js';
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
+import type { MenuItem } from '@/types/menu';
import XDetails from '@/components/MkReactionsViewer.details.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import * as os from '@/os.js';
@@ -36,6 +37,7 @@ import { customEmojisMap } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
import { noteEvents } from '@/composables/use-note-capture.js';
+import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as isEmojiMuted } from '@/utility/emoji-mute.js';
const props = defineProps<{
noteId: Misskey.entities.Note['id'];
@@ -63,6 +65,7 @@ const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i && emoji.value;
});
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
+const isLocalCustomEmoji = props.reaction[0] === ':' && props.reaction.includes('@.');
async function toggleReaction() {
if (!canToggle.value) return;
@@ -139,21 +142,55 @@ async function toggleReaction() {
}
async function menu(ev) {
- if (!canGetInfo.value) return;
+ let menuItems: MenuItem[] = [];
- os.popupMenu([{
- text: i18n.ts.info,
- icon: 'ti ti-info-circle',
- action: async () => {
- const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
- emoji: await misskeyApiGet('emoji', {
- name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
- }),
- }, {
- closed: () => dispose(),
- });
- },
- }], ev.currentTarget ?? ev.target);
+ if (canGetInfo.value) {
+ menuItems.push({
+ text: i18n.ts.info,
+ icon: 'ti ti-info-circle',
+ action: async () => {
+ const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
+ emoji: await misskeyApiGet('emoji', {
+ name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
+ }),
+ }, {
+ closed: () => dispose(),
+ });
+ },
+ });
+ }
+
+ if (isEmojiMuted(props.reaction).value) {
+ menuItems.push({
+ text: i18n.ts.emojiUnmute,
+ icon: 'ti ti-mood-smile',
+ action: () => {
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.unmuteX({ x: isLocalCustomEmoji ? `:${emojiName.value}:` : props.reaction }),
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ unmuteEmoji(props.reaction);
+ });
+ },
+ });
+ } else {
+ menuItems.push({
+ text: i18n.ts.emojiMute,
+ icon: 'ti ti-mood-off',
+ action: () => {
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.muteX({ x: isLocalCustomEmoji ? `:${emojiName.value}:` : props.reaction }),
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ muteEmoji(props.reaction);
+ });
+ },
+ });
+ }
+
+ os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
function anime() {
diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue
index dda45ceaa2..ed114d8d31 100644
--- a/packages/frontend/src/components/global/MkCustomEmoji.vue
+++ b/packages/frontend/src/components/global/MkCustomEmoji.vue
@@ -5,7 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<img
- v-if="errored && fallbackToImage"
+ v-if="shouldMute"
+ :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
+ src="/client-assets/unknown.png"
+ :title="alt"
+ draggable="false"
+ style="-webkit-user-drag: none;"
+ @click="onClick"
+/>
+<img
+ v-else-if="errored && fallbackToImage"
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
src="/client-assets/dummy.png"
:title="alt"
@@ -40,6 +49,7 @@ import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialo
import { $i } from '@/i.js';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
+import { makeEmojiMuteKey, mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkEmojiMuted } from '@/utility/emoji-mute';
const props = defineProps<{
name: string;
@@ -51,12 +61,16 @@ const props = defineProps<{
menu?: boolean;
menuReaction?: boolean;
fallbackToImage?: boolean;
+ ignoreMuted?: boolean;
}>();
const react = inject(DI.mfmEmojiReactCallback);
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
+const emojiCodeToMute = makeEmojiMuteKey(props);
+const isMuted = checkEmojiMuted(emojiCodeToMute);
+const shouldMute = computed(() => !props.ignoreMuted && isMuted.value);
const rawUrl = computed(() => {
if (props.url) {
@@ -95,14 +109,18 @@ function onClick(ev: MouseEvent) {
menuItems.push({
type: 'label',
text: `:${props.name}:`,
- }, {
- text: i18n.ts.copy,
- icon: 'ti ti-copy',
- action: () => {
- copyToClipboard(`:${props.name}:`);
- },
});
+ if (isLocal.value) {
+ menuItems.push({
+ text: i18n.ts.copy,
+ icon: 'ti ti-copy',
+ action: () => {
+ copyToClipboard(`:${props.name}:`);
+ },
+ });
+ }
+
if (props.menuReaction && react) {
menuItems.push({
text: i18n.ts.doReaction,
@@ -113,21 +131,43 @@ function onClick(ev: MouseEvent) {
});
}
- menuItems.push({
- text: i18n.ts.info,
- icon: 'ti ti-info-circle',
- action: async () => {
- const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
- emoji: await misskeyApiGet('emoji', {
- name: customEmojiName.value,
- }),
- }, {
- closed: () => dispose(),
- });
- },
- });
+ if (isLocal.value) {
+ menuItems.push({
+ type: 'divider',
+ }, {
+ text: i18n.ts.info,
+ icon: 'ti ti-info-circle',
+ action: async () => {
+ const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
+ emoji: await misskeyApiGet('emoji', {
+ name: customEmojiName.value,
+ }),
+ }, {
+ closed: () => dispose(),
+ });
+ },
+ });
+ }
- if ($i?.isModerator ?? $i?.isAdmin) {
+ if (isMuted.value) {
+ menuItems.push({
+ text: i18n.ts.emojiUnmute,
+ icon: 'ti ti-mood-smile',
+ action: async () => {
+ await unmute();
+ },
+ });
+ } else {
+ menuItems.push({
+ text: i18n.ts.emojiMute,
+ icon: 'ti ti-mood-off',
+ action: async () => {
+ await mute();
+ },
+ });
+ }
+
+ if (($i?.isModerator ?? $i?.isAdmin) && isLocal.value) {
menuItems.push({
text: i18n.ts.edit,
icon: 'ti ti-pencil',
@@ -152,6 +192,36 @@ async function edit(name: string) {
});
}
+function mute() {
+ const titleEmojiName = isLocal.value
+ ? `:${customEmojiName.value}:`
+ : emojiCodeToMute;
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.muteX({ x: titleEmojiName }),
+ }).then(({ canceled }) => {
+ if (canceled) {
+ return;
+ }
+ muteEmoji(emojiCodeToMute);
+ });
+}
+
+function unmute() {
+ const titleEmojiName = isLocal.value
+ ? `:${customEmojiName.value}:`
+ : emojiCodeToMute;
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.unmuteX({ x: titleEmojiName }),
+ }).then(({ canceled }) => {
+ if (canceled) {
+ return;
+ }
+ unmuteEmoji(emojiCodeToMute);
+ });
+}
+
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
index fa55fd888b..792f9c7d6f 100644
--- a/packages/frontend/src/components/global/MkEmoji.vue
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
+<img v-if="shouldMute" :class="$style.root" src="/client-assets/unknown.png" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
+<img v-else-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span>
</template>
@@ -18,11 +19,13 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
+import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkMutedEmoji } from '@/utility/emoji-mute.js';
const props = defineProps<{
emoji: string;
menu?: boolean;
menuReaction?: boolean;
+ ignoreMuted?: boolean;
}>();
const react = inject(DI.mfmEmojiReactCallback, null);
@@ -32,12 +35,38 @@ const char2path = prefer.s.emojiStyle === 'twemoji' ? char2twemojiFilePath : cha
const useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native');
const url = computed(() => char2path(props.emoji));
const colorizedNativeEmoji = computed(() => colorizeEmoji(props.emoji));
+const isMuted = checkMutedEmoji(props.emoji);
+const shouldMute = computed(() => isMuted.value && !props.ignoreMuted);
// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
function computeTitle(event: PointerEvent): void {
(event.target as HTMLElement).title = getEmojiName(props.emoji);
}
+function mute() {
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.muteX({ x: props.emoji }),
+ }).then(({ canceled }) => {
+ if (canceled) {
+ return;
+ }
+ muteEmoji(props.emoji);
+ });
+}
+
+function unmute() {
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.unmuteX({ x: props.emoji }),
+ }).then(({ canceled }) => {
+ if (canceled) {
+ return;
+ }
+ unmuteEmoji(props.emoji);
+ });
+}
+
function onClick(ev: MouseEvent) {
if (props.menu) {
const menuItems: MenuItem[] = [];
@@ -63,6 +92,22 @@ function onClick(ev: MouseEvent) {
});
}
+ menuItems.push({
+ type: 'divider',
+ }, isMuted.value ? {
+ text: i18n.ts.emojiUnmute,
+ icon: 'ti ti-mood-smile',
+ action: () => {
+ unmute();
+ },
+ } : {
+ text: i18n.ts.emojiMute,
+ icon: 'ti ti-mood-off',
+ action: () => {
+ mute();
+ },
+ });
+
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
}
diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts
index 337e326ccd..3ad2fda0ee 100644
--- a/packages/frontend/src/components/global/MkMfm.ts
+++ b/packages/frontend/src/components/global/MkMfm.ts
@@ -435,6 +435,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
normal: props.plain,
host: props.author.host,
useOriginalSize: scale >= 2.5,
+ menu: props.enableEmojiMenu,
+ menuReaction: false,
})];
}
}
diff --git a/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue b/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue
new file mode 100644
index 0000000000..601ca7ee49
--- /dev/null
+++ b/packages/frontend/src/pages/settings/mute-block.emoji-mute.vue
@@ -0,0 +1,105 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.emojis">
+ <div v-for="emoji in emojis" :key="`emojiMute-${emoji}`" :class="$style.emoji" @click="onEmojiClick($event, emoji)">
+ <MkCustomEmoji
+ v-if="emoji.startsWith(':')"
+ :name="customEmojiName(emoji)"
+ :host="customEmojiHost(emoji)"
+ :normal="true"
+ :menu="false"
+ :menuReaction="false"
+ :ignoreMuted="true"
+ />
+ <MkEmoji
+ v-else
+ :emoji="emoji"
+ :menu="false"
+ :menuReaction="false"
+ :ignoreMuted="true"
+ ></MkEmoji>
+ </div>
+</div>
+
+<MkButton primary inline @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+</template>
+
+<script lang="ts" setup>
+import type { MenuItem } from '@/types/menu';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+import {
+ mute as muteEmoji,
+ unmute as unmuteEmoji,
+ extractCustomEmojiName as customEmojiName,
+ extractCustomEmojiHost as customEmojiHost,
+} from '@/utility/emoji-mute.js';
+
+const emojis = prefer.model('mutingEmojis');
+
+function getHTMLElement(ev: MouseEvent): HTMLElement {
+ const target = ev.currentTarget ?? ev.target;
+ return target as HTMLElement;
+}
+
+function add(ev: MouseEvent) {
+ os.pickEmoji(getHTMLElement(ev), { showPinned: false }).then((emoji) => {
+ if (emoji) {
+ muteEmoji(emoji);
+ }
+ });
+}
+
+function onEmojiClick(ev: MouseEvent, emoji: string) {
+ const menuItems : MenuItem[] = [{
+ type: 'label',
+ text: emoji,
+ }, {
+ text: i18n.ts.emojiUnmute,
+ icon: 'ti ti-mood-off',
+ action: () => unmute(emoji),
+ }];
+ os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
+}
+
+function unmute(emoji: string) {
+ os.confirm({
+ type: 'question',
+ title: i18n.tsx.unmuteX({ x: emoji }),
+ }).then(({ canceled }) => {
+ if (canceled) {
+ return;
+ }
+ unmuteEmoji(emoji);
+ });
+}
+</script>
+<style module>
+.emojis {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+
+ &:empty {
+ display: none;
+ }
+}
+
+.emoji {
+ display: inline-flex;
+ height: 42px;
+ padding: 0 6px;
+ font-size: 1.5em;
+ border-radius: 6px;
+ align-items: center;
+ justify-content: center;
+ background: var(--MI_THEME-buttonBg);
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index 7c2376249e..9b24501cce 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -50,6 +50,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</SearchMarker>
<SearchMarker
+ :label="i18n.ts.emojiMute"
+ :keywords="['emoji', 'mute', 'hide']"
+ >
+ <MkFolder>
+ <template #icon><i class="ti ti-mood-off"></i></template>
+ <template #label>{{ i18n.ts.emojiMute }}</template>
+
+ <div class="_gaps_m">
+ <XEmojiMute/>
+ </div>
+ </mkfolder>
+ </SearchMarker>
+
+ <SearchMarker
:label="i18n.ts.instanceMute"
:keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']"
>
@@ -163,6 +177,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
+import XEmojiMute from './mute-block.emoji-mute.vue';
import XInstanceMute from './mute-block.instance-mute.vue';
import XWordMute from './mute-block.word-mute.vue';
import MkPagination from '@/components/MkPagination.vue';
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index f6cd2c0cb9..b3af8cab87 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -345,6 +345,9 @@ export const PREF_DEF = {
plugins: {
default: [] as Plugin[],
},
+ mutingEmojis: {
+ default: [] as string[],
+ },
'sound.masterVolume': {
default: 0.3,
diff --git a/packages/frontend/src/utility/emoji-mute.ts b/packages/frontend/src/utility/emoji-mute.ts
new file mode 100644
index 0000000000..a058bcc242
--- /dev/null
+++ b/packages/frontend/src/utility/emoji-mute.ts
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { computed } from 'vue';
+import { prefer } from '@/preferences.js';
+
+// custom絵文字の情報からキーを作成する
+export function makeEmojiMuteKey(props: { name: string; host?: string | null }) {
+ return props.name.startsWith(':') ? props.name : `:${props.name}${props.host ? `@${props.host}` : ''}:`;
+}
+
+export function extractCustomEmojiName (name:string) {
+ return (name[0] === ':' ? name.substring(1, name.length - 1) : name).replace('@.', '').split('@')[0];
+}
+
+export function extractCustomEmojiHost (name:string) {
+ // nameは:emojiName@host:の形式
+ // 取り出したい部分はhostなので、@以降を取り出す
+ const index = name.indexOf('@');
+ if (index === -1) {
+ return null;
+ }
+ const host = name.substring(index + 1, name.length - 1);
+ if (host === '' || host === '.') {
+ return null;
+ }
+ return host;
+}
+
+export function mute(emoji: string) {
+ const isCustomEmoji = emoji.startsWith(':') && emoji.endsWith(':');
+ const emojiMuteKey = isCustomEmoji ?
+ makeEmojiMuteKey({ name: extractCustomEmojiName(emoji), host: extractCustomEmojiHost(emoji) }) :
+ emoji;
+ const mutedEmojis = prefer.r.mutingEmojis.value;
+ if (!mutedEmojis.includes(emojiMuteKey)) {
+ return prefer.commit('mutingEmojis', [...mutedEmojis, emojiMuteKey]);
+ }
+ throw new Error('Emoji is already muted', { cause: `${emojiMuteKey} is Already Muted` });
+}
+
+export function unmute(emoji:string) {
+ const isCustomEmoji = emoji.startsWith(':') && emoji.endsWith(':');
+ const emojiMuteKey = isCustomEmoji ?
+ makeEmojiMuteKey({ name: extractCustomEmojiName(emoji), host: extractCustomEmojiHost(emoji) }) :
+ emoji;
+ const mutedEmojis = prefer.r.mutingEmojis.value;
+ console.log('unmute', emoji, emojiMuteKey);
+ console.log('mutedEmojis', mutedEmojis);
+ prefer.commit('mutingEmojis', mutedEmojis.filter((e) => e !== emojiMuteKey));
+}
+
+export function checkMuted(emoji: string) {
+ const isCustomEmoji = emoji.startsWith(':') && emoji.endsWith(':');
+ const emojiMuteKey = isCustomEmoji ?
+ makeEmojiMuteKey({ name: extractCustomEmojiName(emoji), host: extractCustomEmojiHost(emoji) }) :
+ emoji;
+ return computed(() => prefer.r.mutingEmojis.value.includes(emojiMuteKey));
+}
diff --git a/packages/frontend/test/init.ts b/packages/frontend/test/init.ts
index 3b6b4d581b..e38338cf95 100644
--- a/packages/frontend/test/init.ts
+++ b/packages/frontend/test/init.ts
@@ -5,6 +5,8 @@
import { vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock';
+import type { Ref } from 'vue';
+import { ref } from 'vue';
const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks();
@@ -27,13 +29,24 @@ export const preferState: Record<string, unknown> = {
code: false,
},
+ mutingEmojis: [],
};
+export let preferReactive: Record<string, Ref<unknown>> = {};
+
+for (const key in preferState) {
+ if (preferState[key] !== undefined) {
+ preferReactive[key] = ref(preferState[key]);
+ }
+}
+
// XXX: store somehow becomes undefined in vitest?
vi.mock('@/preferences.js', () => {
+
return {
prefer: {
s: preferState,
+ r: preferReactive,
},
};
});