diff options
| author | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
| commit | 6b554c178b81f13f83a69b19d44b72b282a0c119 (patch) | |
| tree | f5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/frontend/src/pages/chat/room.vue | |
| parent | merge: Security fixes (!970) (diff) | |
| parent | bump version for release (diff) | |
| download | sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.gz sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.bz2 sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.zip | |
merge: release 2025.4.2 (!1051)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1051
Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/frontend/src/pages/chat/room.vue')
| -rw-r--r-- | packages/frontend/src/pages/chat/room.vue | 517 |
1 files changed, 517 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..5afda5682f --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,517 @@ +<!-- +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"> + <div v-if="tab === 'chat'" class="_spacer" style="--MI_SPACER-w: 700px;"> + <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" + > + <div 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> + </div> + </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> + </div> + + <div v-else-if="tab === 'search'" class="_spacer" style="--MI_SPACER-w: 700px;"> + <XSearch :userId="userId" :roomId="roomId"/> + </div> + + <div v-else-if="tab === 'members'" class="_spacer" style="--MI_SPACER-w: 700px;"> + <XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/> + </div> + + <div v-else-if="tab === 'info'" class="_spacer" style="--MI_SPACER-w: 700px;"> + <XInfo v-if="room != null" :room="room"/> + </div> + + <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> |