1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-02 20:15:55 +02:00

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
13 changed files with 409 additions and 36 deletions

View File

@@ -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() {

View File

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

View File

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

View File

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