<!-- SPDX-FileCopyrightText: syuilo and misskey-project 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 class="_gaps"> <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> <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"> <XSearch :userId="userId" :roomId="roomId"/> </MkSpacer> <MkSpacer v-else-if="tab === 'members'" :contentMax="700"> <XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/> </MkSpacer> <MkSpacer v-else-if="tab === 'info'" :contentMax="700"> <XInfo v-if="room != null" :room="room"/> </MkSpacer> <template #footer> <div v-if="tab === 'chat'" :class="$style.footer"> <div class="_gaps"> <Transition name="fade"> <div v-show="showIndicator" :class="$style.new"> <button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick"> <i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts._chat.newMessage }} </button> </div> </Transition> <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/> </div> </div> </template> </PageWithHeader> </template> <script lang="ts" setup> import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; import * as Misskey from 'misskey-js'; import { getScrollContainer } from '@@/js/scroll.js'; import XMessage from './XMessage.vue'; import XForm from './room.form.vue'; import XSearch from './room.search.vue'; import XMembers from './room.members.vue'; import XInfo from './room.info.vue'; import type { MenuItem } from '@/types/menu.js'; import type { PageHeaderItem } from '@/types/page-header.js'; import * as os from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/utility/sound.js'; import { i18n } from '@/i18n.js'; import { ensureSignin } from '@/i.js'; import { misskeyApi } from '@/utility/misskey-api.js'; 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'; import MkInfo from '@/components/MkInfo.vue'; import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; const $i = ensureSignin(); const router = useRouter(); const props = defineProps<{ userId?: string; roomId?: string; }>(); export type NormalizedChatMessage = Omit<Misskey.entities.ChatMessageLite, 'fromUser' | 'reactions'> & { fromUser: Misskey.entities.UserLite; reactions: (Misskey.entities.ChatMessageLite['reactions'][number] & { user: Misskey.entities.UserLite; })[]; }; const initializing = ref(true); const moreFetching = ref(false); const messages = ref<NormalizedChatMessage[]>([]); const canFetchMore = ref(false); const user = ref<Misskey.entities.UserDetailed | null>(null); const room = ref<Misskey.entities.ChatRoom | null>(null); const connection = ref<Misskey.IChannelConnection<Misskey.Channels['chatUser']> | Misskey.IChannelConnection<Misskey.Channels['chatRoom']> | null>(null); const showIndicator = ref(false); const timelineEl = useTemplateRef('timelineEl'); const timeline = makeDateSeparatedTimelineComputedRef(messages); const SCROLL_HEAD_THRESHOLD = 200; // column-reverseなので本来はスクロール位置の最下部への追従は不要なはずだが、おそらくブラウザのバグにより、最下部にスクロールした状態でも追従されない場合がある(スクロール位置が少数になることがあるのが関わっていそう) // そのため補助としてMutationObserverを使って追従を行う useMutationObserver(timelineEl, { subtree: true, childList: true, attributes: false, }, () => { const scrollContainer = getScrollContainer(timelineEl.value)!; // column-reverseなのでscrollTopは負になる if (-scrollContainer.scrollTop < SCROLL_HEAD_THRESHOLD) { scrollContainer.scrollTo({ top: 0, behavior: 'instant', }); } }); function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage): NormalizedChatMessage { return { ...message, fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user.value!), reactions: message.reactions.map(record => ({ ...record, user: record.user ?? (message.fromUserId === $i.id ? user.value! : $i), })), }; } async function initialize() { const LIMIT = 20; initializing.value = true; if (props.userId) { const [u, m] = await Promise.all([ misskeyApi('users/show', { userId: props.userId }), misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }), ]); user.value = u; messages.value = m.map(x => normalizeMessage(x)); if (messages.value.length === LIMIT) { canFetchMore.value = true; } connection.value = useStream().useChannel('chatUser', { otherId: user.value.id, }); 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 }), misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }), ]); room.value = r as Misskey.entities.ChatRoomsShowResponse; messages.value = (m as Misskey.entities.ChatMessagesRoomTimelineResponse).map(x => normalizeMessage(x)); if (messages.value.length === LIMIT) { canFetchMore.value = true; } connection.value = useStream().useChannel('chatRoom', { roomId: room.value.id, }); 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); initializing.value = false; } let isActivated = true; onActivated(() => { isActivated = true; }); onDeactivated(() => { isActivated = false; }); async function fetchMore() { const LIMIT = 30; moreFetching.value = true; const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', { userId: user.value!.id, limit: LIMIT, untilId: messages.value[messages.value.length - 1].id, }) : await misskeyApi('chat/messages/room-timeline', { roomId: room.value!.id, limit: LIMIT, untilId: messages.value[messages.value.length - 1].id, }); messages.value.push(...newMessages.map(x => normalizeMessage(x))); canFetchMore.value = newMessages.length === LIMIT; moreFetching.value = false; } function onMessage(message: Misskey.entities.ChatMessageLite) { sound.playMisskeySfx('chatMessage'); messages.value.unshift(normalizeMessage(message)); // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) { connection.value?.send('read', { id: message.id, }); } if (message.fromUserId !== $i.id) { //notifyNewMessage(); } } function onDeleted(id: string) { const index = messages.value.findIndex(m => m.id === id); if (index !== -1) { messages.value.splice(index, 1); } } function onReact(ctx: Parameters<Misskey.Channels['chatUser']['events']['react']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['react']>[0]) { const message = messages.value.find(m => m.id === ctx.messageId); if (message) { if (room.value == null) { // 1on1の時はuserは省略される message.reactions.push({ reaction: ctx.reaction, user: message.fromUserId === $i.id ? user.value! : $i, }); } else { message.reactions.push({ reaction: ctx.reaction, user: ctx.user!, }); } } } function onUnreact(ctx: Parameters<Misskey.Channels['chatUser']['events']['unreact']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['unreact']>[0]) { 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; } function notifyNewMessage() { showIndicator.value = true; } function onVisibilitychange() { if (window.document.hidden) return; // TODO } onMounted(() => { initialize(); }); onBeforeUnmount(() => { connection.value?.dispose(); window.document.removeEventListener('visibilitychange', onVisibilitychange); }); async function inviteUser() { if (room.value == null) return; const invitee = await os.selectUser({ includeSelf: false, localOnly: true }); os.apiWithDialog('chat/rooms/invitations/create', { roomId: room.value.id, userId: invitee.id, }); } async function leaveRoom() { if (room.value == null) return; const { canceled } = await os.confirm({ type: 'warning', text: i18n.ts.areYouSure, }); if (canceled) return; misskeyApi('chat/rooms/leave', { roomId: room.value.id, }); router.push('/chat'); } function showMenu(ev: MouseEvent) { const menuItems: MenuItem[] = []; if (room.value) { if (room.value.ownerId === $i.id) { menuItems.push({ text: i18n.ts._chat.inviteUser, icon: 'ti ti-user-plus', action: () => { inviteUser(); }, }); } else { menuItems.push({ text: i18n.ts._chat.leave, icon: 'ti ti-x', action: () => { leaveRoom(); }, }); } } os.popupMenu(menuItems, ev.currentTarget ?? ev.target); } const tab = ref('chat'); const headerTabs = computed(() => room.value ? [{ key: 'chat', title: i18n.ts.chat, icon: 'ti ti-messages', }, { key: 'members', title: i18n.ts._chat.members, icon: 'ti ti-users', }, { key: 'search', title: i18n.ts.search, icon: 'ti ti-search', }, { key: 'info', title: i18n.ts.info, icon: 'ti ti-info-circle', }] : [{ key: 'chat', title: i18n.ts.chat, icon: 'ti ti-messages', }, { key: 'search', title: i18n.ts.search, icon: 'ti ti-search', }]); const headerActions = computed<PageHeaderItem[]>(() => [{ icon: 'ti ti-dots', text: '', handler: showMenu, }]); definePage(computed(() => { if (!initializing.value) { if (user.value) { return { userName: user.value, title: user.value.name ?? user.value.username, avatar: user.value, }; } else if (room.value) { return { title: room.value.name, icon: 'ti ti-users', }; } else { return { title: i18n.ts.chat, }; } } else { return { title: i18n.ts.chat, }; } })); </script> <style lang="scss" module> .transition_x_move, .transition_x_enterActive, .transition_x_leaveActive { transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; } .transition_x_enterFrom, .transition_x_leaveTo { opacity: 0; transform: translateY(80px); } .transition_x_leaveActive { position: absolute; } .root { } .more { margin: 0 auto; } .footer { width: 100%; padding-top: 8px; } .new { width: 100%; padding-bottom: 8px; text-align: center; } .newButton { display: inline-block; margin: 0; padding: 0 12px; line-height: 32px; font-size: 12px; border-radius: 16px; } .newIcon { display: inline-block; margin-right: 8px; } .footer { } .form { margin: 0 auto; width: 100%; max-width: 700px; } .fade-enter-active, .fade-leave-active { transition: opacity 0.1s; } .fade-enter-from, .fade-leave-to { transition: opacity 0.5s; opacity: 0; } .dateDivider { display: flex; font-size: 85%; align-items: center; justify-content: center; gap: 0.5em; opacity: 0.75; border: solid 0.5px var(--MI_THEME-divider); border-radius: 999px; width: fit-content; padding: 0.5em 1em; margin: 0 auto; } </style>