summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages/chat/room.vue
diff options
context:
space:
mode:
authorJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
committerJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
commit6b554c178b81f13f83a69b19d44b72b282a0c119 (patch)
treef5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/frontend/src/pages/chat/room.vue
parentmerge: Security fixes (!970) (diff)
parentbump version for release (diff)
downloadsharkey-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.vue517
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>