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
	 syuilo
					syuilo