enhance: チャットの閲覧を無効化できるように (#15765)

* enhance: チャットの閲覧を無効化できるように

* fix

* fix

* fix

* readonlyの説明を追加

* enhance: チャットが無効な場合はチャット関連の設定も隠すように

* fix

* refactor: ChatServiceからApiに関するドメイン知識を排除
This commit is contained in:
かっこかり 2025-04-07 19:09:11 +09:00 committed by GitHub
parent 6c27ab12eb
commit 9d3f3264fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 255 additions and 136 deletions

View file

@ -114,6 +114,7 @@ export const navbarItemDef = reactive({
title: i18n.ts.chat,
icon: 'ti ti-messages',
to: '/chat',
show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'),
indicated: computed(() => $i != null && $i.hasUnreadChatMessages),
},
achievements: {

View file

@ -165,21 +165,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
<template #label>{{ i18n.ts._role._options.canChat }}</template>
<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>
<span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span>
<span v-if="role.policies.chatAvailability.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.chatAvailability.value === 'available' ? i18n.ts.yes : role.policies.chatAvailability.value === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.chatAvailability)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly">
<MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly">
<MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<option value="available">{{ i18n.ts.enabled }}</option>
<option value="readonly">{{ i18n.ts.readonly }}</option>
<option value="unavailable">{{ i18n.ts.disabled }}</option>
</MkSelect>
<MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>

View file

@ -51,12 +51,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
<template #label>{{ i18n.ts._role._options.canChat }}</template>
<template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canChat">
<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template>
<MkSelect v-model="policies.chatAvailability">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<option value="available">{{ i18n.ts.enabled }}</option>
<option value="readonly">{{ i18n.ts.readonly }}</option>
<option value="unavailable">{{ i18n.ts.disabled }}</option>
</MkSelect>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
@ -295,6 +298,7 @@ import MkInput from '@/components/MkInput.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRange from '@/components/MkRange.vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import * as os from '@/os.js';

View file

@ -85,7 +85,7 @@ const isMe = computed(() => props.message.fromUserId === $i.id);
const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
provide(DI.mfmEmojiReactCallback, (reaction) => {
if (!$i.policies.canChat) return;
if ($i.policies.chatAvailability !== 'available') return;
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
@ -95,7 +95,7 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
});
function react(ev: MouseEvent) {
if (!$i.policies.canChat) return;
if ($i.policies.chatAvailability !== 'available') return;
const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target);
if (!targetEl) return;
@ -110,7 +110,7 @@ function react(ev: MouseEvent) {
}
function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) {
if (!$i.policies.canChat) return;
if ($i.policies.chatAvailability !== 'available') return;
if (record.user.id === $i.id) {
misskeyApi('chat/messages/unreact', {
@ -138,7 +138,7 @@ function onContextmenu(ev: MouseEvent) {
function showMenu(ev: MouseEvent, contextmenu = false) {
const menu: MenuItem[] = [];
if (!isMe.value && $i.policies.canChat) {
if (!isMe.value && $i.policies.chatAvailability === 'available') {
menu.push({
text: i18n.ts.reaction,
icon: 'ti ti-mood-plus',
@ -164,7 +164,7 @@ function showMenu(ev: MouseEvent, contextmenu = false) {
type: 'divider',
});
if (isMe.value && $i.policies.canChat) {
if (isMe.value && $i.policies.chatAvailability === 'available') {
menu.push({
text: i18n.ts.delete,
icon: 'ti ti-trash',

View file

@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_gaps">
<MkButton v-if="$i.policies.canChat" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
<MkButton v-if="$i.policies.chatAvailability === 'available'" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
<MkInfo v-else>{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<MkInfo v-else>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>

View file

@ -6,54 +6,56 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions">
<MkSpacer v-if="tab === 'chat'" :contentMax="700">
<div v-if="initializing">
<MkLoading/>
</div>
<div v-else-if="messages.length === 0">
<div class="_gaps" style="text-align: center;">
<div>{{ i18n.ts._chat.noMessagesYet }}</div>
<template v-if="user">
<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-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
</template>
<template v-else-if="room">
<div>{{ i18n.ts._chat.inviteUserToChat }}</div>
</template>
</div>
</div>
<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 class="_gaps">
<div v-if="initializing">
<MkLoading/>
</div>
<TransitionGroup
:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps"
>
<template v-for="item in timeline.toReversed()" :key="item.id">
<XMessage v-if="item.type === 'item'" :message="item.data"/>
<div v-else-if="item.type === 'date'" :class="$style.dateDivider">
<span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
</div>
</template>
</TransitionGroup>
</div>
<div v-else-if="messages.length === 0">
<div class="_gaps" style="text-align: center;">
<div>{{ i18n.ts._chat.noMessagesYet }}</div>
<template v-if="user">
<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-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
</template>
<template v-else-if="room">
<div>{{ i18n.ts._chat.inviteUserToChat }}</div>
</template>
</div>
</div>
<div v-if="user && (!user.canChat || user.host !== null)">
<MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo>
</div>
<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>
<MkInfo v-if="!$i.policies.canChat" warn>{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<TransitionGroup
:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps"
>
<template v-for="item in timeline.toReversed()" :key="item.id">
<XMessage v-if="item.type === 'item'" :message="item.data"/>
<div v-else-if="item.type === 'date'" :class="$style.dateDivider">
<span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
</div>
</template>
</TransitionGroup>
</div>
<div v-if="user && (!user.canChat || user.host !== null)">
<MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo>
</div>
<MkInfo v-if="$i.policies.chatAvailability !== 'available'" warn>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
</div>
</MkSpacer>
<MkSpacer v-else-if="tab === 'search'" :contentMax="700">

View file

@ -379,44 +379,46 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['chat', 'messaging']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
<template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></template>
<template v-if="$i.policies.chatAvailability !== 'unavailable'">
<SearchMarker :keywords="['chat', 'messaging']">
<MkFolder>
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
<template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></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>
<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>
<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>
<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>
</template>
</MkSwitch>
</MkPreferenceContainer>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
</template>
<SearchMarker :keywords="['accessibility']">
<MkFolder>
@ -732,6 +734,9 @@ import MkFeatureBanner from '@/components/MkFeatureBanner.vue';
import { globalEvents } from '@/events.js';
import { claimAchievement } from '@/utility/achievements.js';
import { instance } from '@/instance.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const lang = ref(miLocalStorage.getItem('lang'));
const dataSaver = ref(prefer.s.dataSaver);

View file

@ -78,19 +78,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</SearchMarker>
<FormSection>
<SearchMarker :keywords="['chat']">
<MkSelect v-model="chatScope" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template>
<option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option>
<option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option>
<option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option>
<option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option>
<option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option>
<template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template>
</MkSelect>
</SearchMarker>
</FormSection>
<SearchMarker :keywords="['chat']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
<div class="_gaps_m">
<MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<SearchMarker :keywords="['chat']">
<MkSelect v-model="chatScope" @update:modelValue="save()">
<template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template>
<option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option>
<option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option>
<option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option>
<option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option>
<option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option>
<template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template>
</MkSelect>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
<SearchMarker :keywords="['lockdown']">
<FormSection>

View file

@ -16,6 +16,10 @@ export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({
errorComponent: MkError,
});
function chatPage(...args: Parameters<typeof page>) {
return $i?.policies.chatAvailability !== 'unavailable' ? page(...args) : page(() => import('@/pages/not-found.vue'));
}
export const ROUTE_DEF = [{
path: '/@:username/pages/:pageName(*)',
component: page(() => import('@/pages/page.vue')),
@ -42,19 +46,19 @@ export const ROUTE_DEF = [{
component: page(() => import('@/pages/clip.vue')),
}, {
path: '/chat',
component: page(() => import('@/pages/chat/home.vue')),
component: chatPage(() => import('@/pages/chat/home.vue')),
loginRequired: true,
}, {
path: '/chat/user/:userId',
component: page(() => import('@/pages/chat/room.vue')),
component: chatPage(() => import('@/pages/chat/room.vue')),
loginRequired: true,
}, {
path: '/chat/room/:roomId',
component: page(() => import('@/pages/chat/room.vue')),
component: chatPage(() => import('@/pages/chat/room.vue')),
loginRequired: true,
}, {
path: '/chat/messages/:messageId',
component: page(() => import('@/pages/chat/message.vue')),
component: chatPage(() => import('@/pages/chat/message.vue')),
loginRequired: true,
}, {
path: '/instance-info/:host',

View file

@ -364,7 +364,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
},
});
if ($i.policies.canChat && user.canChat && user.host == null) {
if ($i.policies.chatAvailability === 'available' && user.canChat && user.host == null) {
menuItems.push({
type: 'link',
icon: 'ti ti-messages',