518 lines
14 KiB
Vue
518 lines
14 KiB
Vue
<!--
|
|
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>
|