diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-03-24 21:32:46 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-24 21:32:46 +0900 |
| commit | f1f24e39d2df3135493e2c2087230b428e2d02b7 (patch) | |
| tree | a5ae0e9d2cf810649b2f4e08ef4d00ce7ea91dc9 /packages/frontend/src/pages/chat | |
| parent | fix(frontend): fix broken styles (diff) | |
| download | misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.gz misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.bz2 misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.zip | |
Feat: Chat (#15686)
* wip
* wip
* wip
* wip
* wip
* wip
* Update types.ts
* Create 1742203321812-chat.js
* wip
* wip
* Update room.vue
* Update home.vue
* Update home.vue
* Update ja-JP.yml
* Update index.d.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* Update home.vue
* clean up
* Update misskey-js.api.md
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* lint fixes
* lint
* Update UserEntityService.ts
* search
* wip
* 🎨
* wip
* Update home.ownedRooms.vue
* wip
* Update CHANGELOG.md
* Update style.scss
* wip
* improve performance
* improve performance
* Update timeline.test.ts
Diffstat (limited to 'packages/frontend/src/pages/chat')
| -rw-r--r-- | packages/frontend/src/pages/chat/XMessage.vue | 245 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/XRoom.vue | 41 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/home.home.vue | 252 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/home.invitations.vue | 98 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/home.joiningRooms.vue | 54 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/home.ownedRooms.vue | 54 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/home.vue | 60 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/message.vue | 55 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/room.form.vue | 333 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/room.info.vue | 87 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/room.members.vue | 73 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/room.search.vue | 68 | ||||
| -rw-r--r-- | packages/frontend/src/pages/chat/room.vue | 426 |
13 files changed, 1846 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue new file mode 100644 index 0000000000..1e7f8e20ea --- /dev/null +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -0,0 +1,245 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, { [$style.isMe]: isMe }]"> + <MkAvatar :class="$style.avatar" :user="message.fromUser" :link="!isMe" :preview="false"/> + <div :class="$style.body"> + <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe"> + <div v-if="!message.isDeleted" :class="$style.content"> + <Mfm v-if="message.text" ref="text" class="_selectable" :text="message.text" :i="$i"/> + <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/> + </div> + <div v-else :class="$style.content"> + <p>{{ i18n.ts.deleted }}</p> + </div> + </MkFukidashi> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/> + <div :class="$style.footer"> + <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button> + <MkTime :class="$style.time" :time="message.createdAt"/> + <MkA v-if="isSearchResult && message.toRoomId" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA> + <MkA v-if="isSearchResult && message.toUserId && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA> + </div> + <TransitionGroup + :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''" + tag="div" :class="$style.reactions" + > + <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction"> + <MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/> + <MkReactionIcon + :withTooltip="true" + :reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')" + :noStyle="true" + :class="$style.reactionIcon" + /> + </div> + </TransitionGroup> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import type { MenuItem } from '@/types/menu.js'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import MkFukidashi from '@/components/MkFukidashi.vue'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import MkMediaList from '@/components/MkMediaList.vue'; +import { reactionPicker } from '@/utility/reaction-picker.js'; +import * as sound from '@/utility/sound.js'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import { prefer } from '@/preferences.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage; + isSearchResult?: boolean; +}>(); + +const isMe = computed(() => props.message.fromUserId === $i.id); +const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function react(ev: MouseEvent) { + reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => { + sound.playMisskeySfx('reaction'); + + misskeyApi('chat/messages/react', { + messageId: props.message.id, + reaction: reaction, + }); + }); +} + +function showMenu(ev: MouseEvent) { + const menu: MenuItem[] = []; + + if (!isMe.value) { + menu.push({ + text: i18n.ts.reaction, + icon: 'ti ti-mood-plus', + action: (ev) => { + react(ev); + }, + }); + + menu.push({ + type: 'divider', + }); + } + + menu.push({ + text: i18n.ts.copyContent, + icon: 'ti ti-copy', + action: () => { + copyToClipboard(props.message.text); + }, + }); + + menu.push({ + type: 'divider', + }); + + if (isMe.value) { + menu.push({ + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: () => { + misskeyApi('chat/messages/delete', { + messageId: props.message.id, + }); + }, + }); + } else { + menu.push({ + text: i18n.ts.reportAbuse, + icon: 'ti ti-exclamation-circle', + action: () => { + const localUrl = `${url}/chat/messages/${props.message.id}`; + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: props.message.fromUser, + initialComment: `${localUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); + }, + }); + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} +</script> + +<style lang="scss" module> +.transition_reaction_move, +.transition_reaction_enterActive, +.transition_reaction_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_reaction_enterFrom, +.transition_reaction_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_reaction_leaveActive { + position: absolute; +} + +.root { + position: relative; + display: flex; + + &.isMe { + flex-direction: row-reverse; + text-align: right; + + .content { + color: var(--MI_THEME-fgOnAccent); + } + + .footer { + flex-direction: row-reverse; + } + } +} + +.avatar { + position: sticky; + top: calc(16px + var(--MI-stickyTop, 0px)); + display: block; + width: 52px; + height: 52px; +} + +.body { + margin: 0 12px; +} + +.content { + overflow: clip; + overflow-wrap: break-word; + word-break: break-word; +} + +.file { +} + +.footer { + display: flex; + flex-direction: row; + gap: 0.5em; + margin-top: 4px; + font-size: 75%; +} + +.time { + opacity: 0.5; +} + +.reactions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + + &:empty { + display: none; + } +} + +.reaction { + display: flex; + align-items: center; + border: solid 1px var(--MI_THEME-divider); + border-radius: 999px; + padding: 8px; +} + +.reactionAvatar { + width: 24px; + height: 24px; + margin-right: 8px; +} + +.reactionIcon { + width: 24px; + height: 24px; +} +</style> diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue new file mode 100644 index 0000000000..b063a0cdd1 --- /dev/null +++ b/packages/frontend/src/pages/chat/XRoom.vue @@ -0,0 +1,41 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root"> + <div :class="$style.header"> + <div style="font-weight: bold;">{{ room.name }}</div> + <MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/> + </div> + <hr> + <div>{{ room.description }}</div> +</MkA> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +</script> + +<style lang="scss" module> +.root { + padding: 16px; +} + +.header { + display: flex; + align-items: center; +} + +.headerAvatar { + width: 30px; + height: 30px; + margin-left: auto; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue new file mode 100644 index 0000000000..1d0605136c --- /dev/null +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -0,0 +1,252 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton> + + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> + + <MkFoldableSection> + <template #header>{{ i18n.ts._chat.history }}</template> + + <div v-if="history.length > 0" class="_gaps_s"> + <MkA + v-for="item in history" + :key="item.id" + :class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]" + class="_panel" + :to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`" + > + <MkAvatar v-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/> + <div :class="$style.messageBody"> + <header v-if="item.message.toRoom" :class="$style.messageHeader"> + <span :class="$style.messageHeaderName">{{ item.message.toRoom.name }}</span> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <header v-else :class="$style.messageHeader"> + <MkUserName :class="$style.messageHeaderName" :user="item.other!"/> + <MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/> + <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/> + </header> + <div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div> + </div> + </MkA> + </div> + <div v-if="!fetching && history.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import { updateCurrentAccountPartial } from '@/accounts.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const history = ref<{ + id: string; + message: Misskey.entities.ChatMessage; + other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null; + isMe: boolean; +}[]>([]); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +function start(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._chat.individualChat, + caption: i18n.ts._chat.individualChat_description, + icon: 'ti ti-user', + action: () => { startUser(); }, + }, { type: 'divider' }, { + type: 'parent', + text: i18n.ts._chat.roomChat, + caption: i18n.ts._chat.roomChat_description, + icon: 'ti ti-users-group', + children: [{ + text: i18n.ts._chat.createRoom, + icon: 'ti ti-plus', + action: () => { createRoom(); }, + }], + }], ev.currentTarget ?? ev.target); +} + +async function startUser() { + os.selectUser().then(user => { + router.push(`/chat/user/${user.id}`); + }); +} + +async function createRoom() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.name, + minLength: 1, + }); + if (canceled) return; + + const room = await misskeyApi('chat/rooms/create', { + name: result, + }); + + router.push(`/chat/room/${room.id}`); +} + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + }); + + searchResults.value = res; + searched.value = true; +} + +async function fetchHistory() { + fetching.value = true; + + const [userMessages, roomMessages] = await Promise.all([ + misskeyApi('chat/history', { room: false }), + misskeyApi('chat/history', { room: true }), + ]); + + history.value = [...userMessages, ...roomMessages] + .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map(m => ({ + id: m.id, + message: m, + other: m.room == null ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null, + isMe: m.fromUserId === $i.id, + })); + + fetching.value = false; + + updateCurrentAccountPartial({ hasUnreadChatMessages: false }); +} + +onMounted(() => { + fetchHistory(); +}); +</script> + +<style lang="scss" module> +.start { + margin: 0 auto; +} + +.message { + position: relative; + display: flex; + padding: 16px 24px; + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + &::before { + content: ''; + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + border-radius: 100%; + background-color: var(--MI_THEME-accent); + } + } +} + +.messageAvatar { + width: 50px; + height: 50px; + margin: 0 16px 0 0; +} + +.messageBody { + flex: 1; + min-width: 0; +} + +.messageHeader { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: clip; +} + +.messageHeaderName { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; +} + +.messageHeaderUsername { + margin: 0 8px; +} + +.messageHeaderTime { + margin-left: auto; +} + +.messageBodyText { + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; +} + +.youSaid { + font-weight: bold; + margin-right: 0.5em; +} + +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue new file mode 100644 index 0000000000..4c3c0b282e --- /dev/null +++ b/packages/frontend/src/pages/chat/home.invitations.vue @@ -0,0 +1,98 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="invitations.length > 0" class="_gaps_s"> + <MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true"> + <template #icon><i class="ti ti-users-group"></i></template> + <template #label>{{ invitation.room.name }}</template> + <template #suffix><MkTime :time="invitation.createdAt"/></template> + <template #footer> + <div class="_buttons"> + <MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton> + <MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton> + </div> + </template> + + <div :class="$style.invitationBody"> + <MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/> + <div style="flex: 1;" class="_gaps_s"> + <MkUserName :user="invitation.room.owner"/> + <hr> + <div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div> + </div> + </div> + </MkFolder> + </div> + <div v-if="!fetching && invitations.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noInvitations }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; +import MkFolder from '@/components/MkFolder.vue'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]); + +async function fetchInvitations() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/invitations/inbox', { + }); + + invitations.value = res; + + fetching.value = false; +} + +async function join(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/join', { + roomId: invitation.room.id, + }); + + router.push(`/chat/room/${invitation.room.id}`); +} + +async function ignore(invitation: Misskey.entities.ChatRoomInvitation) { + await misskeyApi('chat/rooms/invitations/ignore', { + roomId: invitation.room.id, + }); + + invitations.value = invitations.value.filter(i => i.id !== invitation.id); +} + +onMounted(() => { + fetchInvitations(); +}); +</script> + +<style lang="scss" module> +.invitationBody { + display: flex; + align-items: center; +} + +.invitationBodyAvatar { + margin-right: 12px; + width: 45px; + height: 45px; +} +</style> diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue new file mode 100644 index 0000000000..63e4d2adf8 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="memberships.length > 0" class="_gaps_s"> + <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room"/> + </div> + <div v-if="!fetching && memberships.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XRoom from './XRoom.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/joining', { + }); + + memberships.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue new file mode 100644 index 0000000000..b0449fb373 --- /dev/null +++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <div v-if="rooms.length > 0" class="_gaps_s"> + <XRoom v-for="room in rooms" :key="room.id" :room="room"/> + </div> + <div v-if="!fetching && rooms.length == 0" class="_fullinfo"> + <div>{{ i18n.ts._chat.noRooms }}</div> + </div> + <MkLoading v-if="fetching"/> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XRoom from './XRoom.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { ensureSignin } from '@/i.js'; +import { useRouter } from '@/router.js'; +import * as os from '@/os.js'; + +const $i = ensureSignin(); + +const router = useRouter(); + +const fetching = ref(true); +const rooms = ref<Misskey.entities.ChatRoom[]>([]); + +async function fetchRooms() { + fetching.value = true; + + const res = await misskeyApi('chat/rooms/owned', { + }); + + rooms.value = res; + + fetching.value = false; +} + +onMounted(() => { + fetchRooms(); +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue new file mode 100644 index 0000000000..c2b272a42d --- /dev/null +++ b/packages/frontend/src/pages/chat/home.vue @@ -0,0 +1,60 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkPolkadots v-if="tab === 'home'" accented/> + <MkSpacer :contentMax="700"> + <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <XHome v-if="tab === 'home'"/> + <XInvitations v-else-if="tab === 'invitations'"/> + <XJoiningRooms v-else-if="tab === 'joiningRooms'"/> + <XOwnedRooms v-else-if="tab === 'ownedRooms'"/> + </MkHorizontalSwipe> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import XHome from './home.home.vue'; +import XInvitations from './home.invitations.vue'; +import XJoiningRooms from './home.joiningRooms.vue'; +import XOwnedRooms from './home.ownedRooms.vue'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +const tab = ref('home'); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => [{ + key: 'home', + title: i18n.ts._chat.home, + icon: 'ti ti-home', +}, { + key: 'invitations', + title: i18n.ts._chat.invitations, + icon: 'ti ti-ticket', +}, { + key: 'joiningRooms', + title: i18n.ts._chat.joiningRooms, + icon: 'ti ti-users-group', +}, { + key: 'ownedRooms', + title: i18n.ts._chat.yourRooms, + icon: 'ti ti-settings', +}]); + +definePage(() => ({ + title: i18n.ts.chat + ' (beta)', + icon: 'ti ti-message', +})); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue new file mode 100644 index 0000000000..be8be7e5d1 --- /dev/null +++ b/packages/frontend/src/pages/chat/message.vue @@ -0,0 +1,55 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader> + <MkSpacer :contentMax="700"> + <div v-if="initializing"> + <MkLoading/> + </div> + <div v-else> + <XMessage :message="message"/> + </div> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { definePage } from '@/page.js'; +import MkButton from '@/components/MkButton.vue'; + +const props = defineProps<{ + messageId?: string; +}>(); + +const initializing = ref(true); +const message = ref<Misskey.entities.ChatMessage>(); + +async function initialize() { + initializing.value = true; + + message.value = await misskeyApi('chat/messages/show', { + messageId: props.messageId, + }); + + initializing.value = false; +} + +onMounted(() => { + initialize(); +}); + +definePage({ + title: i18n.ts.chat, +}); +</script> diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue new file mode 100644 index 0000000000..aba9d6061f --- /dev/null +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -0,0 +1,333 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="$style.root" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + ref="textareaEl" + v-model="text" + :class="$style.textarea" + class="_acrylic" + :placeholder="i18n.ts.inputMessageHere" + :readonly="textareaReadOnly" + @keydown="onKeydown" + @paste="onPaste" + ></textarea> + <footer :class="$style.footer"> + <div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div> + <div :class="$style.buttons"> + <button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button> + <button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button> + <button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template> + </button> + </div> + </footer> + <input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly } from 'vue'; +import * as Misskey from 'misskey-js'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { throttle } from 'throttle-debounce'; +import { formatTimeString } from '@/utility/format-time-string.js'; +import { selectFile } from '@/utility/select-file.js'; +import * as os from '@/os.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { uploadFile } from '@/utility/upload.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; +import { Autocomplete } from '@/utility/autocomplete.js'; +import { emojiPicker } from '@/utility/emoji-picker.js'; + +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + room?: Misskey.entities.ChatRoom | null; +}>(); + +const textareaEl = shallowRef<HTMLTextAreaElement>(); +const fileEl = shallowRef<HTMLInputElement>(); + +const text = ref<string>(''); +const file = ref<Misskey.entities.DriveFile | null>(null); +const sending = ref(false); +const textareaReadOnly = ref(false); + +const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null); + +function getDraftKey() { + return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id; +} + +watch([text, file], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]'; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); + } + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + } + } +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + switch (ev.dataTransfer.effectAllowed) { + case 'all': + case 'uninitialized': + case 'copy': + case 'copyLink': + case 'copyMove': + ev.dataTransfer.dropEffect = 'copy'; + break; + case 'linkMove': + case 'move': + ev.dataTransfer.dropEffect = 'move'; + break; + default: + ev.dataTransfer.dropEffect = 'none'; + break; + } + } +} + +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; + + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } + + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file.value = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} + +function onKeydown(ev: KeyboardEvent) { + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) { + send(); + } +} + +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file.value = selectedFile; + }); +} + +function onChangeFile() { + if (fileEl.value.files![0]) upload(fileEl.value.files[0]); +} + +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { + file.value = res; + }); +} + +function send() { + if (!canSend.value) return; + + sending.value = true; + + if (props.user) { + misskeyApi('chat/messages/create-to-user', { + toUserId: props.user.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } else if (props.room) { + misskeyApi('chat/messages/create-to-room', { + toRoomId: props.room.id, + text: text.value ? text.value : undefined, + fileId: file.value ? file.value.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending.value = false; + }); + } +} + +function clear() { + text.value = ''; + file.value = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + drafts[getDraftKey()] = { + updatedAt: new Date(), + data: { + text: text.value, + file: file.value, + }, + }; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +function deleteDraft() { + const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}'); + + delete drafts[getDraftKey()]; + + miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts)); +} + +async function insertEmoji(ev: MouseEvent) { + textareaReadOnly.value = true; + const target = ev.currentTarget ?? ev.target; + if (target == null) return; + + // emojiPickerはダイアログが閉じずにtextareaとやりとりするので、 + // focustrapをかけているとinsertTextAtCursorが効かない + // そのため、投稿フォームのテキストに直接注入する + // See: https://github.com/misskey-dev/misskey/pull/14282 + // https://github.com/misskey-dev/misskey/issues/14274 + + let pos = textareaEl.value?.selectionStart ?? 0; + let posEnd = textareaEl.value?.selectionEnd ?? text.value.length; + emojiPicker.show( + target as HTMLElement, + emoji => { + const textBefore = text.value.substring(0, pos); + const textAfter = text.value.substring(posEnd); + text.value = textBefore + emoji + textAfter; + pos += emoji.length; + posEnd += emoji.length; + }, + () => { + textareaReadOnly.value = false; + nextTick(() => focus()); + }, + ); +} + +onMounted(() => { + // TODO: detach when unmount + new Autocomplete(textareaEl.value, text); + + // 書きかけの投稿を復元 + const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()]; + if (draft) { + text.value = draft.data.text; + file.value = draft.data.file; + } +}); +</script> + +<style lang="scss" module> +.root { + position: relative; + border-bottom: none; + border-radius: 14px 14px 0 0; + overflow: clip; +} + +.textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + color: var(--MI_THEME-fg); + field-sizing: content; +} + +.footer { + position: sticky; + bottom: 0; + background: var(--MI_THEME-panel); +} + +.file { + padding: 8px; + cursor: pointer; +} + +.buttons { + display: flex; +} + +.button { + height: 50px; + aspect-ratio: 1; + + &:hover { + color: var(--MI_THEME-accent); + } +} +.send { + margin-left: auto; + color: var(--MI_THEME-accent); +} +</style> diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..7d38d07b3a --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,87 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput v-model="name_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description_" :disabled="!isOwner"> + <template #label>{{ i18n.ts.description }}</template> + </MkTextarea> + + <MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton> + + <hr> + + <MkSwitch v-if="!isOwner" v-model="isMuted"> + <template #label>{{ i18n.ts._chat.muteThisRoom }}</template> + </MkSwitch> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import { ensureSignin } from '@/i.js'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const name_ = ref(props.room.name); +const description_ = ref(props.room.description); + +function save() { + os.apiWithDialog('chat/rooms/update', { + roomId: props.room.id, + name: name_.value, + description: description_.value, + }); +} + +const isMuted = ref(props.room.isMuted); + +watch(isMuted, async () => { + await os.apiWithDialog('chat/rooms/mute', { + roomId: props.room.id, + mute: isMuted.value, + }); +}); + +onMounted(async () => { + +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..d20216a81c --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,73 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton> + + <MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`"> + <MkUserCardMini :user="room.owner"/> + </MkA> + + <hr> + + <div v-for="membership in memberships" :key="membership.id" :class="$style.membership"> + <MkA :class="$style.membershipBody" :to="`${userPage(membership.user)}`"> + <MkUserCardMini :user="membership.user"/> + </MkA> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const props = defineProps<{ + room: Misskey.entities.ChatRoom; +}>(); + +const emit = defineEmits<{ + (ev: 'inviteUser'): void, +}>(); + +const isOwner = computed(() => { + return props.room.ownerId === $i.id; +}); + +const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]); + +onMounted(async () => { + memberships.value = await misskeyApi('chat/rooms/members', { + roomId: props.room.id, + limit: 50, + }); +}); +</script> + +<style lang="scss" module> +.membership { + display: flex; +} + +.membershipBody { + flex: 1; + min-width: 0; + margin-right: 8px; + + &:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..de5e7156ca --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,68 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps"> + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts._chat.searchMessages" + type="search" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton> + + <MkFoldableSection v-if="searched"> + <template #header>{{ i18n.ts.searchResult }}</template> + + <div class="_gaps_s"> + <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem"> + <XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/> + </div> + </div> + </MkFoldableSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import XMessage from './XMessage.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import * as os from '@/os.js'; +import MkInput from '@/components/MkInput.vue'; +import MkFoldableSection from '@/components/MkFoldableSection.vue'; + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const searchQuery = ref(''); +const searched = ref(false); +const searchResults = ref<Misskey.entities.ChatMessage[]>([]); + +async function search() { + const res = await misskeyApi('chat/messages/search', { + query: searchQuery.value, + roomId: props.roomId, + userId: props.userId, + }); + + searchResults.value = res; + searched.value = true; +} +</script> + +<style lang="scss" module> +.searchResultItem { + padding: 12px; + border: solid 1px var(--MI_THEME-divider); + border-radius: 12px; +} +</style> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..15e9f43db2 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,426 @@ +<!-- +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 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>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div> + </template> + <template v-else-if="room"> + <div>{{ i18n.ts._chat.inviteUserToChat }}</div> + </template> + </div> + </div> + + <div v-else 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" + > + <XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message"/> + </TransitionGroup> + </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.newMessageExists }} + </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, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue'; +import * as Misskey from 'misskey-js'; +import { isTailVisible } 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 * 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'; + +const $i = ensureSignin(); +const router = useRouter(); + +const props = defineProps<{ + userId?: string; + roomId?: string; +}>(); + +const initializing = ref(true); +const moreFetching = ref(false); +const messages = ref<Misskey.entities.ChatMessage[]>([]); +const canFetchMore = ref(false); +const user = ref<Misskey.entities.UserDetailed | null>(null); +const room = ref<Misskey.entities.ChatRoom | null>(null); +const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chatUser'] | Misskey.Channels['chatRoom']> | null>(null); +const showIndicator = ref(false); + +function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage) { + const reactions = [...message.reactions]; + for (const record of reactions) { + if (room.value == null && record.user == null) { // 1on1の時はuserは省略される + record.user = message.fromUserId === $i.id ? user.value : $i; + } + } + + return { + ...message, + fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user), + reactions, + }; +} + +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); + } 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; + messages.value = m.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); + } + + 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.ChatMessage) { + 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) { + const index = messages.value.findIndex(m => m.id === id); + if (index !== -1) { + messages.value.splice(index, 1); + } +} + +function onReact(ctx) { + 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 : $i, + }); + } else { + message.reactions.push({ + reaction: ctx.reaction, + user: ctx.user, + }); + } + } +} + +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() { + 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() { + 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(() => [{ + icon: 'ti ti-dots', + handler: showMenu, +}]); + +definePage(computed(() => !initializing.value ? user.value ? { + userName: user, + avatar: user, +} : { + title: room.value?.name, + icon: 'ti ti-users', +} : null)); +</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; +} +</style> |