Merge branch 'misskey-develop' into merge/2025-03-24

# Conflicts:
#	package.json
#	packages/backend/src/core/entities/NotificationEntityService.ts
#	packages/backend/src/types.ts
#	packages/frontend/src/pages/admin/modlog.ModLog.vue
#	packages/misskey-js/src/consts.ts
#	packages/misskey-js/src/entities.ts
This commit is contained in:
Hazelnoot 2025-03-25 16:17:34 -04:00
commit 40975719ec
82 changed files with 1563 additions and 298 deletions

View file

@ -354,7 +354,6 @@ defineExpose({
&.wide {
display: flex;
margin: 0 auto;
height: 100%;
> .nav {
position: sticky;

View file

@ -58,6 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
'deletePage',
'deleteFlash',
'deleteGalleryPost',
'deleteChatRoom',
'clearUserFiles',
'clearRemoteFiles',
'clearOwnerlessFiles',
@ -115,6 +116,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'deletePage'">: @{{ log.info.pageUserUsername }}</span>
<span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span>
<span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span>
<span v-else-if="log.type === 'deleteChatRoom'">: @{{ log.info.room.name }}</span>
<span v-else-if="log.type === 'clearUserFiles'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'nsfwUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unNsfwUser'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>

View file

@ -7,9 +7,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.root, { [$style.isMe]: isMe }]">
<MkAvatar :class="$style.avatar" :user="message.fromUser" :link="!isMe" :preview="false"/>
<div :class="$style.body">
<div v-if="!isMe && prefer.s['chat.showSenderName']" :class="$style.header"><MkUserName :user="message.fromUser"/></div>
<MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe">
<div v-if="!message.isDeleted" :class="$style.content">
<Mfm v-if="message.text" ref="text" class="_selectable" :text="message.text" :i="$i"/>
<Mfm
v-if="message.text"
ref="text"
class="_selectable"
:text="message.text"
:i="$i"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
</div>
<div v-else :class="$style.content">
@ -31,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
tag="div" :class="$style.reactions"
>
<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction">
<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="[$style.reaction, record.user.id === $i.id ? $style.reactionMy : null]" @click="onReactionClick(record)">
<MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
<MkReactionIcon
:withTooltip="true"
@ -46,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent } from 'vue';
import { computed, defineAsyncComponent, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
@ -64,6 +74,7 @@ import { reactionPicker } from '@/utility/reaction-picker.js';
import * as sound from '@/utility/sound.js';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
const $i = ensureSignin();
@ -75,10 +86,17 @@ const props = defineProps<{
const isMe = computed(() => props.message.fromUserId === $i.id);
const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
provide(DI.mfmEmojiReactCallback, (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
messageId: props.message.id,
reaction: reaction,
});
});
function react(ev: MouseEvent) {
reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
messageId: props.message.id,
reaction: reaction,
@ -86,6 +104,23 @@ function react(ev: MouseEvent) {
});
}
function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) {
if (record.user.id === $i.id) {
misskeyApi('chat/messages/unreact', {
messageId: props.message.id,
reaction: record.reaction,
});
} else {
if (!props.message.reactions.some(r => r.user.id === $i.id && r.reaction === record.reaction)) {
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
messageId: props.message.id,
reaction: record.reaction,
});
}
}
}
function showMenu(ev: MouseEvent) {
const menu: MenuItem[] = [];
@ -191,6 +226,10 @@ function showMenu(ev: MouseEvent) {
margin: 0 12px;
}
.header {
font-size: 80%;
}
.content {
overflow: clip;
overflow-wrap: break-word;
@ -230,6 +269,10 @@ function showMenu(ev: MouseEvent) {
border: solid 1px var(--MI_THEME-divider);
border-radius: 999px;
padding: 8px;
&.reactionMy {
border-color: var(--MI_THEME-accent);
}
}
.reactionAvatar {

View file

@ -40,10 +40,11 @@ SPDX-License-Identifier: AGPL-3.0-only
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName">{{ item.message.toRoom.name }}</span>
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
@ -55,17 +56,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkA>
</div>
<div v-if="!fetching && history.length == 0" class="_fullinfo">
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="fetching"/>
<MkLoading v-if="initializing"/>
</MkFoldableSection>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { computed, onActivated, onDeactivated, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import XMessage from './XMessage.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
@ -81,7 +83,8 @@ const $i = ensureSignin();
const router = useRouter();
const fetching = ref(true);
const initializing = ref(true);
const fetching = ref(false);
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
@ -142,6 +145,8 @@ async function search() {
}
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
@ -159,10 +164,35 @@ async function fetchHistory() {
}));
fetching.value = false;
initializing.value = false;
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => {
fetchHistory();
});

View file

@ -52,7 +52,7 @@ const headerTabs = computed(() => [{
definePage(() => ({
title: i18n.ts.chat + ' (beta)',
icon: 'ti ti-message',
icon: 'ti ti-messages',
}));
</script>

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading/>
</div>
<div v-else>
<XMessage :message="message"/>
<XMessage :message="message" :isSearchResult="true"/>
</div>
</MkSpacer>
</PageWithHeader>

View file

@ -151,8 +151,16 @@ function onDrop(ev: DragEvent): void {
}
function onKeydown(ev: KeyboardEvent) {
if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) {
send();
if (ev.key === 'Enter') {
if (prefer.s['chat.sendOnEnter']) {
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
send();
}
} else {
if ((ev.ctrlKey || ev.metaKey)) {
send();
}
}
}
}

View file

@ -17,6 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<hr>
<MkButton v-if="isOwner || ($i.isAdmin || $i.isModerator)" danger @click="del">{{ i18n.ts._chat.deleteRoom }}</MkButton>
<MkSwitch v-if="!isOwner" v-model="isMuted">
<template #label>{{ i18n.ts._chat.muteThisRoom }}</template>
</MkSwitch>
@ -34,7 +36,9 @@ import { ensureSignin } from '@/i.js';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { useRouter } from '@/router.js';
const router = useRouter();
const $i = ensureSignin();
const props = defineProps<{
@ -56,6 +60,19 @@ function save() {
});
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
});
if (canceled) return;
misskeyApi('chat/rooms/delete', {
roomId: props.room.id,
});
router.push('/chat');
}
const isMuted = ref(props.room.isMuted);
watch(isMuted, async () => {

View file

@ -18,6 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserCardMini :user="membership.user"/>
</MkA>
</div>
<template v-if="isOwner">
<hr>
<div>{{ i18n.ts._chat.sentInvitations }}</div>
<div v-for="invitation in invitations" :key="invitation.id" :class="$style.invitation">
<MkA :class="$style.invitationBody" :to="`${userPage(invitation.user)}`">
<MkUserCardMini :user="invitation.user"/>
</MkA>
</div>
</template>
</div>
</template>
@ -47,12 +59,20 @@ const isOwner = computed(() => {
});
const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]);
onMounted(async () => {
memberships.value = await misskeyApi('chat/rooms/members', {
roomId: props.room.id,
limit: 50,
});
if (isOwner.value) {
invitations.value = await misskeyApi('chat/rooms/invitations/outbox', {
roomId: props.room.id,
limit: 50,
});
}
});
</script>
@ -65,9 +85,15 @@ onMounted(async () => {
flex: 1;
min-width: 0;
margin-right: 8px;
}
&:hover {
text-decoration: none;
}
.invitation {
display: flex;
}
.invitationBody {
flex: 1;
min-width: 0;
margin-right: 8px;
}
</style>

View file

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div>
<div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div>
<div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div>
<div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
<div v-else-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
</template>
<template v-else-if="room">
<div>{{ i18n.ts._chat.inviteUserToChat }}</div>
@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else class="_gaps">
<div v-else ref="timelineEl" class="_gaps">
<div v-if="canFetchMore">
<MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton>
</div>
@ -75,7 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
import * as Misskey from 'misskey-js';
import { isTailVisible } from '@@/js/scroll.js';
import { getScrollContainer, isTailVisible } from '@@/js/scroll.js';
import XMessage from './XMessage.vue';
import XForm from './room.form.vue';
import XSearch from './room.search.vue';
@ -92,6 +92,7 @@ import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue';
import { useRouter } from '@/router.js';
import { useMutationObserver } from '@/use/use-mutation-observer.js';
const $i = ensureSignin();
const router = useRouter();
@ -109,6 +110,26 @@ const user = ref<Misskey.entities.UserDetailed | null>(null);
const room = ref<Misskey.entities.ChatRoom | null>(null);
const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chatUser'] | Misskey.Channels['chatRoom']> | null>(null);
const showIndicator = ref(false);
const timelineEl = useTemplateRef('timelineEl');
const SCROLL_HEAD_THRESHOLD = 200;
// column-reverse()
// MutationObserver使
useMutationObserver(timelineEl, {
subtree: true,
childList: true,
attributes: false,
}, () => {
const scrollContainer = getScrollContainer(timelineEl.value)!;
// column-reversescrollTop
if (-scrollContainer.scrollTop < SCROLL_HEAD_THRESHOLD) {
scrollContainer.scrollTo({
top: 0,
behavior: 'instant',
});
}
});
function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage) {
const reactions = [...message.reactions];
@ -149,6 +170,7 @@ async function initialize() {
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
connection.value.on('react', onReact);
connection.value.on('unreact', onUnreact);
} else {
const [r, m] = await Promise.all([
misskeyApi('chat/rooms/show', { roomId: props.roomId }),
@ -168,6 +190,7 @@ async function initialize() {
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
connection.value.on('react', onReact);
connection.value.on('unreact', onUnreact);
}
window.document.addEventListener('visibilitychange', onVisibilitychange);
@ -247,6 +270,16 @@ function onReact(ctx) {
}
}
function onUnreact(ctx) {
const message = messages.value.find(m => m.id === ctx.messageId);
if (message) {
const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user.id);
if (index !== -1) {
message.reactions.splice(index, 1);
}
}
}
function onIndicatorClick() {
showIndicator.value = false;
}

View file

@ -4,45 +4,55 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<FormSlot>
<template #label>{{ i18n.ts.navbar }}</template>
<MkContainer :showHeader="false">
<Sortable
v-model="items"
itemKey="id"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
@end="e => e.item.classList.remove('active')"
>
<template #item="{element,index}">
<div
v-if="element.type === '-' || navbarItemDef[element.type]"
:class="$style.item"
>
<button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button>
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span>
<button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button>
</div>
</template>
</Sortable>
</MkContainer>
</FormSlot>
<div class="_buttons">
<MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton>
<MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton primary class="save" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
<SearchMarker path="/settings/navbar" :label="i18n.ts.navbar" icon="ti ti-list" :keywords="['navbar', 'menu', 'sidebar']">
<div class="_gaps_m">
<FormSlot>
<template #label>{{ i18n.ts.navbar }}</template>
<MkContainer :showHeader="false">
<Sortable
v-model="items"
itemKey="id"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
@end="e => e.item.classList.remove('active')"
>
<template #item="{element,index}">
<div
v-if="element.type === '-' || navbarItemDef[element.type]"
:class="$style.item"
>
<button class="_button" :class="$style.itemHandle"><i class="ti ti-menu"></i></button>
<i class="ti-fw" :class="[$style.itemIcon, navbarItemDef[element.type]?.icon]"></i><span :class="$style.itemText">{{ navbarItemDef[element.type]?.title ?? i18n.ts.divider }}</span>
<button class="_button" :class="$style.itemRemove" @click="removeItem(index)"><i class="ti ti-x"></i></button>
</div>
</template>
</Sortable>
</MkContainer>
</FormSlot>
<div class="_buttons">
<MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton>
<MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
<MkButton primary class="save" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
<MkRadios v-model="menuDisplay">
<template #label>{{ i18n.ts.display }}</template>
<option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option>
<option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option>
<option value="top">{{ i18n.ts._menuDisplay.top }}</option>
<MkRadios v-model="menuDisplay">
<template #label>{{ i18n.ts.display }}</template>
<option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option>
<option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option>
<option value="top">{{ i18n.ts._menuDisplay.top }}</option>
<!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
</MkRadios>
</div>
</MkRadios>
<SearchMarker :keywords="['navbar', 'sidebar', 'toggle', 'button', 'sub']">
<MkPreferenceContainer k="showNavbarSubButtons">
<MkSwitch v-model="showNavbarSubButtons">
<template #label><SearchLabel>{{ i18n.ts._settings.showNavbarSubButtons }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</SearchMarker>
</template>
<script lang="ts" setup>
@ -51,6 +61,8 @@ import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSlot from '@/components/form/slot.vue';
import MkContainer from '@/components/MkContainer.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue';
import * as os from '@/os.js';
import { navbarItemDef } from '@/navbar.js';
import { store } from '@/store.js';
@ -68,6 +80,7 @@ const items = ref(prefer.s.menu.map(x => ({
})));
const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
const showNavbarSubButtons = prefer.model('showNavbarSubButtons');
async function addItem() {
const menu = Object.keys(navbarItemDef).filter(k => !prefer.s.menu.includes(k));

View file

@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SearchMarker :keywords="['general']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.general }}</SearchLabel></template>
<template #icon><i class="ti ti-settings"></i></template>
<div class="_gaps_m">
<SearchMarker :keywords="['language']">
@ -135,6 +136,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SearchMarker :keywords="['timeline', 'note']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts._settings.timelineAndNote }}</SearchLabel></template>
<template #icon><i class="ti ti-notes"></i></template>
<div class="_gaps_m">
<div class="_gaps_s">
@ -293,6 +295,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SearchMarker :keywords="['post', 'form']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.postForm }}</SearchLabel></template>
<template #icon><i class="ti ti-edit"></i></template>
<div class="_gaps_m">
<div class="_gaps_s">
@ -354,6 +357,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SearchMarker :keywords="['notification']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.notifications }}</SearchLabel></template>
<template #icon><i class="ti ti-bell"></i></template>
<div class="_gaps_m">
<SearchMarker :keywords="['group']">
@ -394,6 +398,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<SearchMarker :keywords="['datasaver']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.dataSaver }}</SearchLabel></template>
<template #icon><i class="ti ti-antenna-bars-3"></i></template>
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.reloadRequiredToApplySettings }}</MkInfo>
@ -424,9 +429,49 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['chat', 'messaging']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
<template #icon><i class="ti ti-messages"></i></template>
<div class="_gaps_s">
<SearchMarker :keywords="['show', 'sender', 'name']">
<MkPreferenceContainer k="chat.showSenderName">
<MkSwitch v-model="chatShowSenderName">
<template #label><SearchLabel>{{ i18n.ts._settings._chat.showSenderName }}</SearchLabel></template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
<SearchMarker :keywords="['send', 'enter', 'newline']">
<MkPreferenceContainer k="chat.sendOnEnter">
<MkSwitch v-model="chatSendOnEnter">
<template #label><SearchLabel>{{ i18n.ts._settings._chat.sendOnEnter }}</SearchLabel></template>
<template #caption>
<div class="_gaps_s">
<div>
<b>{{ i18n.ts._settings.ifOn }}:</b>
<div>{{ i18n.ts._chat.send }}: Enter</div>
<div>{{ i18n.ts._chat.newline }}: Shift + Enter</div>
</div>
<div>
<b>{{ i18n.ts._settings.ifOff }}:</b>
<div>{{ i18n.ts._chat.send }}: Ctrl + Enter</div>
<div>{{ i18n.ts._chat.newline }}: Enter</div>
</div>
</div>
</template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['other']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.other }}</SearchLabel></template>
<template #icon><i class="ti ti-settings-cog"></i></template>
<div class="_gaps_m">
<div class="_gaps_s">
@ -603,6 +648,8 @@ const emojiStyle = prefer.model('emojiStyle');
const useBlurEffectForModal = prefer.model('useBlurEffectForModal');
const useBlurEffect = prefer.model('useBlurEffect');
const defaultFollowWithReplies = prefer.model('defaultFollowWithReplies');
const chatShowSenderName = prefer.model('chat.showSenderName');
const chatSendOnEnter = prefer.model('chat.sendOnEnter');
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@ -630,6 +677,7 @@ watch([
squareAvatars,
highlightSensitiveMedia,
enableSeasonalScreenEffect,
chatShowSenderName,
], async () => {
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
});