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