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 442742c371.

* Update Changelog
This commit is contained in:
taichan 2025-05-12 10:00:06 +09:00 committed by GitHub
parent b18d6b4cef
commit 5bc52b6743
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 409 additions and 36 deletions

View File

@ -14,6 +14,8 @@
- 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です - 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です
- 従来のWebsocket接続を行うモードはリアルタイムモードとして再定義されました - 従来のWebsocket接続を行うモードはリアルタイムモードとして再定義されました
- チャットなど、一部の機能は引き続き設定に関わらずWebsocket接続が行われます - チャットなど、一部の機能は引き続き設定に関わらずWebsocket接続が行われます
- Feat: 絵文字をミュート可能にする機能
- 絵文字(ユニコードの絵文字・カスタム絵文字)毎にミュートし、不可視化することができるようになりました
- Enhance: メモリ使用量を軽減しました - Enhance: メモリ使用量を軽減しました
- Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加 - Enhance: 画像の高品質なプレースホルダを無効化してパフォーマンスを向上させるオプションを追加
- Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように - Enhance: 招待されているが参加していないルームを開いたときに、招待を承認するかどうか尋ねるように

16
locales/index.d.ts vendored
View File

@ -5425,6 +5425,22 @@ export interface Locale extends ILocale {
* *
*/ */
"turnItOff": string; "turnItOff": string;
/**
*
*/
"emojiMute": string;
/**
*
*/
"emojiUnmute": string;
/**
* {x}
*/
"muteX": ParameterizedString<"x">;
/**
* {x}
*/
"unmuteX": ParameterizedString<"x">;
"_chat": { "_chat": {
/** /**
* *

View File

@ -1351,6 +1351,10 @@ advice: "アドバイス"
realtimeMode: "リアルタイムモード" realtimeMode: "リアルタイムモード"
turnItOn: "オンにする" turnItOn: "オンにする"
turnItOff: "オフにする" turnItOff: "オフにする"
emojiMute: "絵文字ミュート"
emojiUnmute: "絵文字ミュート解除"
muteX: "{x}をミュート"
unmuteX: "{x}のミュートを解除"
_chat: _chat:
noMessagesYet: "まだメッセージはありません" noMessagesYet: "まだメッセージはありません"

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -22,6 +22,7 @@ import { computed, inject, onMounted, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { getUnicodeEmoji } from '@@/js/emojilist.js'; import { getUnicodeEmoji } from '@@/js/emojilist.js';
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
import type { MenuItem } from '@/types/menu';
import XDetails from '@/components/MkReactionsViewer.details.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -36,6 +37,7 @@ import { customEmojisMap } from '@/custom-emojis.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { noteEvents } from '@/composables/use-note-capture.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<{ const props = defineProps<{
noteId: Misskey.entities.Note['id']; noteId: Misskey.entities.Note['id'];
@ -63,6 +65,7 @@ const canToggle = computed(() => {
return !props.reaction.match(/@\w/) && $i && emoji.value; return !props.reaction.match(/@\w/) && $i && emoji.value;
}); });
const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':')); const canGetInfo = computed(() => !props.reaction.match(/@\w/) && props.reaction.includes(':'));
const isLocalCustomEmoji = props.reaction[0] === ':' && props.reaction.includes('@.');
async function toggleReaction() { async function toggleReaction() {
if (!canToggle.value) return; if (!canToggle.value) return;
@ -139,21 +142,55 @@ async function toggleReaction() {
} }
async function menu(ev) { async function menu(ev) {
if (!canGetInfo.value) return; let menuItems: MenuItem[] = [];
os.popupMenu([{ if (canGetInfo.value) {
text: i18n.ts.info, menuItems.push({
icon: 'ti ti-info-circle', text: i18n.ts.info,
action: async () => { icon: 'ti ti-info-circle',
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { action: async () => {
emoji: await misskeyApiGet('emoji', { const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
name: props.reaction.replace(/:/g, '').replace(/@\./, ''), emoji: await misskeyApiGet('emoji', {
}), name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
}, { }),
closed: () => dispose(), }, {
}); closed: () => dispose(),
}, });
}], ev.currentTarget ?? ev.target); },
});
}
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() { function anime() {

View File

@ -5,7 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<img <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 }]" :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
src="/client-assets/dummy.png" src="/client-assets/dummy.png"
:title="alt" :title="alt"
@ -40,6 +49,7 @@ import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialo
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { makeEmojiMuteKey, mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkEmojiMuted } from '@/utility/emoji-mute';
const props = defineProps<{ const props = defineProps<{
name: string; name: string;
@ -51,12 +61,16 @@ const props = defineProps<{
menu?: boolean; menu?: boolean;
menuReaction?: boolean; menuReaction?: boolean;
fallbackToImage?: boolean; fallbackToImage?: boolean;
ignoreMuted?: boolean;
}>(); }>();
const react = inject(DI.mfmEmojiReactCallback); const react = inject(DI.mfmEmojiReactCallback);
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); 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 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(() => { const rawUrl = computed(() => {
if (props.url) { if (props.url) {
@ -95,14 +109,18 @@ function onClick(ev: MouseEvent) {
menuItems.push({ menuItems.push({
type: 'label', type: 'label',
text: `:${props.name}:`, 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) { if (props.menuReaction && react) {
menuItems.push({ menuItems.push({
text: i18n.ts.doReaction, text: i18n.ts.doReaction,
@ -113,21 +131,43 @@ function onClick(ev: MouseEvent) {
}); });
} }
menuItems.push({ if (isLocal.value) {
text: i18n.ts.info, menuItems.push({
icon: 'ti ti-info-circle', type: 'divider',
action: async () => { }, {
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { text: i18n.ts.info,
emoji: await misskeyApiGet('emoji', { icon: 'ti ti-info-circle',
name: customEmojiName.value, action: async () => {
}), const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
}, { emoji: await misskeyApiGet('emoji', {
closed: () => dispose(), 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({ menuItems.push({
text: i18n.ts.edit, text: i18n.ts.edit,
icon: 'ti ti-pencil', 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> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -4,7 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <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> <span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span>
</template> </template>
@ -18,11 +19,13 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { DI } from '@/di.js'; import { DI } from '@/di.js';
import { mute as muteEmoji, unmute as unmuteEmoji, checkMuted as checkMutedEmoji } from '@/utility/emoji-mute.js';
const props = defineProps<{ const props = defineProps<{
emoji: string; emoji: string;
menu?: boolean; menu?: boolean;
menuReaction?: boolean; menuReaction?: boolean;
ignoreMuted?: boolean;
}>(); }>();
const react = inject(DI.mfmEmojiReactCallback, null); 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 useOsNativeEmojis = computed(() => prefer.s.emojiStyle === 'native');
const url = computed(() => char2path(props.emoji)); const url = computed(() => char2path(props.emoji));
const colorizedNativeEmoji = computed(() => colorizeEmoji(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 // 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 { function computeTitle(event: PointerEvent): void {
(event.target as HTMLElement).title = getEmojiName(props.emoji); (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) { function onClick(ev: MouseEvent) {
if (props.menu) { if (props.menu) {
const menuItems: MenuItem[] = []; 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); os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
} }
} }

View File

@ -435,6 +435,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
normal: props.plain, normal: props.plain,
host: props.author.host, host: props.author.host,
useOriginalSize: scale >= 2.5, useOriginalSize: scale >= 2.5,
menu: props.enableEmojiMenu,
menuReaction: false,
})]; })];
} }
} }

View File

@ -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>

View File

@ -49,6 +49,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder> </MkFolder>
</SearchMarker> </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 <SearchMarker
:label="i18n.ts.instanceMute" :label="i18n.ts.instanceMute"
:keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']" :keywords="['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide']"
@ -163,6 +177,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import XEmojiMute from './mute-block.emoji-mute.vue';
import XInstanceMute from './mute-block.instance-mute.vue'; import XInstanceMute from './mute-block.instance-mute.vue';
import XWordMute from './mute-block.word-mute.vue'; import XWordMute from './mute-block.word-mute.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';

View File

@ -345,6 +345,9 @@ export const PREF_DEF = {
plugins: { plugins: {
default: [] as Plugin[], default: [] as Plugin[],
}, },
mutingEmojis: {
default: [] as string[],
},
'sound.masterVolume': { 'sound.masterVolume': {
default: 0.3, default: 0.3,

View File

@ -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));
}

View File

@ -5,6 +5,8 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock'; import createFetchMock from 'vitest-fetch-mock';
import type { Ref } from 'vue';
import { ref } from 'vue';
const fetchMocker = createFetchMock(vi); const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks(); fetchMocker.enableMocks();
@ -27,13 +29,24 @@ export const preferState: Record<string, unknown> = {
code: false, 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? // XXX: store somehow becomes undefined in vitest?
vi.mock('@/preferences.js', () => { vi.mock('@/preferences.js', () => {
return { return {
prefer: { prefer: {
s: preferState, s: preferState,
r: preferReactive,
}, },
}; };
}); });