wip ( 絵文字ミュートの基礎実装, PoC )

This commit is contained in:
tai-cha 2025-05-06 19:20:24 +09:00
parent d476f7ff50
commit 6384f03c8e
No known key found for this signature in database
GPG Key ID: 1D5EE39F870DC283
8 changed files with 276 additions and 19 deletions

12
locales/index.d.ts vendored
View File

@ -5417,6 +5417,18 @@ export interface Locale extends ILocale {
*
*/
"scrollToClose": string;
/**
*
*/
"emojiMute": string;
/**
* {x}
*/
"muteX": ParameterizedString<"x">;
/**
* {x}
*/
"unmuteX": ParameterizedString<"x">;
"_chat": {
/**
*

View File

@ -1349,6 +1349,9 @@ goToDeck: "デッキへ戻る"
federationJobs: "連合ジョブ"
driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。<br>\nートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。<br>\n<b>ファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。</b><br>\nフォルダを作って整理することもできます。"
scrollToClose: "スクロールして閉じる"
emojiMute: "絵文字ミュート"
muteX: "{x}をミュート"
unmuteX: "{x}のミュートを解除"
_chat:
noMessagesYet: "まだメッセージはありません"

View File

@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<img
v-if="errored && fallbackToImage"
v-if="(errored || shouldMute ) && fallbackToImage"
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
src="/client-assets/dummy.png"
:title="alt"
draggable="false"
style="-webkit-user-drag: none;"
/>
<span v-else-if="errored">:{{ customEmojiName }}:</span>
<span v-else-if="errored || shouldMute">:{{ customEmojiName }}:</span>
<img
v-else
:class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]"
@ -51,12 +51,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 = props.name.startsWith(':') ? props.name : `:${props.name}${props.host ? `@${props.host}` : ''}:`;
const isMuted = computed(() => prefer.r.mutingEmojis.value.includes(emojiCodeToMute));
const shouldMute = computed(() => !props.ignoreMuted && isMuted.value);
const rawUrl = computed(() => {
if (props.url) {
@ -95,13 +99,17 @@ function onClick(ev: MouseEvent) {
menuItems.push({
type: 'label',
text: `:${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({
@ -113,7 +121,10 @@ function onClick(ev: MouseEvent) {
});
}
if (isLocal.value) {
menuItems.push({
type: 'divider',
}, {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: async () => {
@ -126,8 +137,17 @@ function onClick(ev: MouseEvent) {
});
},
});
}
if ($i?.isModerator ?? $i?.isAdmin) {
menuItems.push({
text: i18n.ts.mute,
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 +172,21 @@ async function edit(name: string) {
});
}
function mute() {
os.confirm({
type: 'question',
title: i18n.tsx.muteX({ x: emojiCodeToMute }),
}).then(({ canceled }) => {
if (canceled) {
return;
}
const mutedEmojis = prefer.r.mutingEmojis.value;
if (!mutedEmojis.includes(emojiCodeToMute)) {
prefer.commit('mutingEmojis', [...mutedEmojis, 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/dummy.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>
@ -23,6 +24,7 @@ const props = defineProps<{
emoji: string;
menu?: boolean;
menuReaction?: boolean;
ignoreMuted?: boolean;
}>();
const react = inject(DI.mfmEmojiReactCallback, null);
@ -32,12 +34,46 @@ 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 = computed(() => prefer.r.mutingEmojis.value.includes(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;
}
const mutedEmojis = prefer.r.mutingEmojis.value;
if (!mutedEmojis.includes(props.emoji)) {
prefer.commit('mutingEmojis', [...mutedEmojis, props.emoji]);
}
});
}
function unmute() {
os.confirm({
type: 'question',
title: i18n.tsx.unmuteX({ x: props.emoji }),
}).then(({ canceled }) => {
if (canceled) {
return;
}
const mutedEmojis = prefer.r.mutingEmojis.value;
const index = mutedEmojis.indexOf(props.emoji);
if (index !== -1) {
mutedEmojis.splice(index, 1);
prefer.commit('mutingEmojis', mutedEmojis);
}
});
}
function onClick(ev: MouseEvent) {
if (props.menu) {
const menuItems: MenuItem[] = [];
@ -63,6 +99,22 @@ function onClick(ev: MouseEvent) {
});
}
menuItems.push({
type: 'divider',
}, isMuted.value ? {
text: i18n.ts.unmute,
icon: 'ti ti-mood-smile',
action: () => {
unmute();
},
} : {
text: i18n.ts.mute,
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,
})];
}
}

View File

@ -0,0 +1,135 @@
<!--
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';
const emojis = prefer.model('mutingEmojis');
function customEmojiName (name:string) {
return (name[0] === ':' ? name.substring(1, name.length - 1) : name).replace('@.', '').split('@')[0];
}
function customEmojiHost (name:string) {
// name:emojiName@host:
// host@
const index = name.indexOf('@');
if (index === -1) {
return null;
}
return name.substring(index + 1, name.length - 1);
}
function getHTMLElement(ev: MouseEvent): HTMLElement {
const target = ev.currentTarget ?? ev.target;
return target as HTMLElement;
}
function mute(emoji: string) {
const emojiCodeToMute = emoji.startsWith(':') ? emoji : `:${emoji}:`;
os.confirm({
type: 'question',
title: i18n.tsx.muteX({ x: emojiCodeToMute }),
}).then(({ canceled }) => {
if (canceled) {
return;
}
const mutedEmojis = prefer.r.mutingEmojis.value;
if (!mutedEmojis.includes(emojiCodeToMute)) {
prefer.commit('mutingEmojis', [...mutedEmojis, emojiCodeToMute]);
}
});
}
function add(ev: MouseEvent) {
os.pickEmoji(getHTMLElement(ev), { showPinned: false }).then((emoji) => {
if (emoji) {
const mutedEmojis = prefer.r.mutingEmojis.value;
if (!mutedEmojis.includes(emoji)) {
prefer.commit('mutingEmojis', [...mutedEmojis, emoji]);
}
}
});
}
function onEmojiClick(ev: MouseEvent, emoji: string) {
const menuItems : MenuItem[] = [{
type: 'label',
text: emoji,
}, {
text: i18n.ts.unmute,
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;
}
const mutedEmojis = prefer.r.mutingEmojis.value;
if (mutedEmojis.includes(emoji)) {
prefer.commit('mutingEmojis', mutedEmojis.filter((e) => e !== 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>
</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']"
@ -178,6 +192,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';

View File

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