This commit is contained in:
syuilo
2025-03-23 10:58:46 +09:00
parent f6737149e0
commit 92f7532eb3
4 changed files with 106 additions and 91 deletions

View File

@@ -1,9 +1,10 @@
## 2025.3.2 ## 2025.3.2
### General ### General
- Feat: チャットが復活しました - Feat: チャットがリニューアルして復活しました
- 既存のDM機能よりも便利で効率的になっています - 既存のDM機能よりも便利で効率的になっています
- チャットを受け付ける相手を制限できます - チャットを受け付ける相手を制限できます
- チャット機能を開放するかどうかをロールで制御できます
- Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 - Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。
- Misskeyネイティブでダッシュボードを実装予定です - Misskeyネイティブでダッシュボードを実装予定です

View File

@@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<button <button
v-if="!link" v-if="!link"
ref="el" class="_button" ref="el" class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:type="type" :type="type"
:name="name" :name="name"
:value="value" :value="value"
:disabled="disabled" :disabled="disabled || wait"
@click="emit('click', $event)" @click="emit('click', $event)"
@mousedown="onMousedown" @mousedown="onMousedown"
> >
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button> </button>
<MkA <MkA
v-else class="_button" v-else class="_button"
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:to="to ?? '#'" :to="to ?? '#'"
:behavior="linkBehavior" :behavior="linkBehavior"
@mousedown="onMousedown" @mousedown="onMousedown"
@@ -256,6 +256,10 @@ function onMousedown(evt: MouseEvent): void {
opacity: 0.5; opacity: 0.5;
} }
&.wait {
cursor: wait !important;
}
&:focus-visible { &:focus-visible {
outline-offset: 2px; outline-offset: 2px;
} }

View File

@@ -26,14 +26,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else ref="rootEl" class="_gaps"> <div v-else ref="rootEl" class="_gaps">
<div v-show="pagination.reversed && more" key="_more_"> <div v-show="pagination.reversed && more" key="_more_">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>
</div> </div>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_"> <div v-show="!pagination.reversed && more" key="_more_">
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore"> <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }} {{ i18n.ts.loadMore }}
</MkButton> </MkButton>
<MkLoading v-else class="loading"/> <MkLoading v-else class="loading"/>

View File

@@ -6,31 +6,36 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader reversed> <PageWithHeader reversed>
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<MkPagination v-if="pagination" ref="pagingComponent" :key="userId || roomId" :pagination="pagination" :disableAutoLoad="true" :scrollReversed="true"> <div v-if="initializing">
<template #empty> <MkLoading/>
<div class="_gaps" style="text-align: center;"> </div>
<div>{{ i18n.ts.noMessagesYet }}</div> <div v-else-if="messages.length === 0">
<template v-if="user"> <div class="_gaps" style="text-align: center;">
<div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div> <div>{{ i18n.ts.noMessagesYet }}</div>
<div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div> <template v-if="user">
<div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div> <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div>
<div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div>
</template> <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div>
</div> <div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
</template> </template>
<template #default="{ items: messages }"> </div>
<TransitionGroup </div>
:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''" <div v-else class="_gaps">
:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''" <div v-if="canFetchMore">
:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''" <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton>
:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''" </div>
:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps" <TransitionGroup
> :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
<XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message" :user="message.fromUserId === $i.id ? $i : user" :isRoom="room != null"/> :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
</TransitionGroup> :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
</template> :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
</MkPagination> :moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps"
>
<XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message" :user="message.fromUserId === $i.id ? $i : user" :isRoom="room != null"/>
</TransitionGroup>
</div>
</MkSpacer> </MkSpacer>
<template #footer> <template #footer>
@@ -43,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button> </button>
</div> </div>
</Transition> </Transition>
<XForm v-if="!fetching" :user="user" :room="room" :class="$style.form"/> <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/>
</div> </div>
</div> </div>
</template> </template>
@@ -51,14 +56,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'; import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { isTailVisible } from '@@/js/scroll.js'; import { isTailVisible } from '@@/js/scroll.js';
import XMessage from './room.message.vue'; import XMessage from './room.message.vue';
import XForm from './room.form.vue'; import XForm from './room.form.vue';
import type { Paging } from '@/components/MkPagination.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import * as sound from '@/utility/sound.js'; import * as sound from '@/utility/sound.js';
@@ -67,6 +69,7 @@ import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue';
const $i = ensureSignin(); const $i = ensureSignin();
@@ -75,36 +78,38 @@ const props = defineProps<{
roomId?: string; roomId?: string;
}>(); }>();
const pagingComponent = useTemplateRef('pagingComponent'); const initializing = ref(true);
const moreFetching = ref(false);
const fetching = ref(true); const messages = ref<Misskey.entities.ChatMessage[]>([]);
const canFetchMore = ref(false);
const user = ref<Misskey.entities.UserDetailed | null>(null); const user = ref<Misskey.entities.UserDetailed | null>(null);
const room = ref<Misskey.entities.ChatRoom | null>(null); const room = ref<Misskey.entities.ChatRoom | null>(null);
const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chat']> | null>(null); const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chat']> | null>(null);
const showIndicator = ref(false); const showIndicator = ref(false);
const pagination = ref<Paging | null>(null);
watch([() => props.userId, () => props.roomId], () => { watch([() => props.userId, () => props.roomId], () => {
if (connection.value) connection.value.dispose(); if (connection.value) connection.value.dispose();
fetch(); initialize();
}); });
async function fetch() { async function initialize() {
fetching.value = true; initializing.value = true;
if (props.userId) { if (props.userId) {
user.value = await misskeyApi('users/show', { userId: props.userId }); const LIMIT = 20;
room.value = null;
const [u, m] = await Promise.all([
misskeyApi('users/show', { userId: props.userId }),
misskeyApi('chat/messages/timeline', { userId: props.userId, limit: LIMIT }),
]);
user.value = u;
messages.value = m;
if (messages.value.length === LIMIT) {
canFetchMore.value = true;
}
pagination.value = {
endpoint: 'chat/messages/timeline',
limit: 20,
params: {
userId: user.value.id,
},
reversed: true,
};
connection.value = useStream().useChannel('chat', { connection.value = useStream().useChannel('chat', {
otherId: user.value.id, otherId: user.value.id,
}); });
@@ -130,28 +135,57 @@ async function fetch() {
window.document.addEventListener('visibilitychange', onVisibilitychange); window.document.addEventListener('visibilitychange', onVisibilitychange);
fetching.value = false; initializing.value = false;
} }
function onMessage(message) { let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
function fetchMore() {
const LIMIT = 30;
moreFetching.value = true;
misskeyApi('chat/messages/timeline', {
userId: user.value.id,
limit: LIMIT,
untilId: messages.value[messages.value.length - 1].id,
}).then(newMessages => {
messages.value.push(...newMessages);
canFetchMore.value = newMessages.length === LIMIT;
moreFetching.value = false;
});
}
function onMessage(message: Misskey.entities.ChatMessage) {
//sound.play('chat'); //sound.play('chat');
pagingComponent.value.prepend(message); messages.value.unshift(message);
if (message.userId !== $i.id && !window.document.hidden) {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) {
connection.value?.send('read', { connection.value?.send('read', {
id: message.id, id: message.id,
}); });
} }
if (message.userId !== $i.id) { if (message.fromUserId !== $i.id) {
notifyNewMessage(); //notifyNewMessage();
} }
} }
function onDeleted(id) { function onDeleted(id) {
const msg = pagingComponent.value.items.find(m => m.id === id); const index = messages.value.findIndex(m => m.id === id);
if (msg) { if (index !== -1) {
pagingComponent.value.items = pagingComponent.value.items.filter(m => m.id !== msg.id); messages.value.splice(index, 1);
} }
} }
@@ -165,17 +199,11 @@ function notifyNewMessage() {
function onVisibilitychange() { function onVisibilitychange() {
if (window.document.hidden) return; if (window.document.hidden) return;
for (const message of pagingComponent.value.items) { // TODO
if (message.userId !== $i.id && !message.isRead) {
connection.value?.send('read', {
id: message.id,
});
}
}
} }
onMounted(() => { onMounted(() => {
fetch(); initialize();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -183,7 +211,7 @@ onBeforeUnmount(() => {
window.document.removeEventListener('visibilitychange', onVisibilitychange); window.document.removeEventListener('visibilitychange', onVisibilitychange);
}); });
definePage(computed(() => !fetching.value ? user.value ? { definePage(computed(() => !initializing.value ? user.value ? {
userName: user, userName: user,
avatar: user, avatar: user,
} : { } : {
@@ -211,25 +239,7 @@ definePage(computed(() => !fetching.value ? user.value ? {
} }
.more { .more {
display: block; margin: 0 auto;
margin: 16px auto;
padding: 0 12px;
line-height: 24px;
color: #fff;
background: rgba(#000, 0.3);
border-radius: 12px;
&:hover {
background: rgba(#000, 0.4);
}
&:active {
background: rgba(#000, 0.5);
}
}
.fetching {
cursor: wait;
} }
.footer { .footer {