diff options
| author | taichan <40626578+tai-cha@users.noreply.github.com> | 2025-05-12 10:00:06 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-12 10:00:06 +0900 |
| commit | 5bc52b6743d1d23d215aaa7b62ceee85f15c3502 (patch) | |
| tree | 0426e5a239f65cadf8ad7a8f3a14dba6c7a68cd3 /packages/frontend/src | |
| parent | lint (diff) | |
| download | misskey-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/frontend/src')
8 files changed, 374 insertions, 36 deletions
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)); +} |