Skip to content

Commit

Permalink
feat(chat): implement chat (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
XxLittleCxX authored Oct 30, 2023
1 parent dfbc366 commit e49ccc0
Show file tree
Hide file tree
Showing 32 changed files with 6,070 additions and 149 deletions.
26 changes: 13 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,34 +25,34 @@
"test:html": "shx rm -rf coverage && c8 -r html yarn test"
},
"devDependencies": {
"@koishijs/eslint-config": "^1.0.0",
"@koishijs/plugin-database-memory": "^2.3.1",
"@koishijs/plugin-mock": "^2.4.3",
"@koishijs/vitepress": "^1.6.5",
"@koishijs/eslint-config": "^1.0.4",
"@koishijs/plugin-database-memory": "^2.3.6",
"@koishijs/plugin-mock": "^2.6.3",
"@koishijs/vitepress": "^3.0.1",
"@sinonjs/fake-timers": "^6.0.1",
"@types/mocha": "^9.1.1",
"@types/node": "^18.15.3",
"@types/node": "^20.4.2",
"@types/sinonjs__fake-timers": "^6.0.4",
"c8": "^7.13.0",
"esbuild": "^0.17.12",
"c8": "^8.0.1",
"esbuild": "^0.19.2",
"esbuild-register": "^3.4.2",
"eslint": "^8.36.0",
"eslint": "^8.45.0",
"eslint-plugin-mocha": "^10.1.0",
"jest-mock": "^28.1.3",
"mocha": "^9.2.2",
"mocha": "^10.2.0",
"sass": "^1.59.3",
"shx": "^0.3.4",
"typescript": "^4.9.5",
"typescript": "^5.2.2",
"yml-register": "^1.1.0",
"yakumo": "^0.3.9",
"yakumo-esbuild": "^0.3.22",
"yakumo-esbuild": "^0.3.26",
"yakumo-esbuild-yaml": "^0.3.1",
"yakumo-mocha": "^0.3.1",
"yakumo-publish": "^0.3.3",
"yakumo-publish-sync": "^0.3.2",
"yakumo-tsc": "^0.3.7",
"yakumo-upgrade": "^0.3.2",
"yakumo-upgrade": "^0.3.4",
"yakumo-version": "^0.3.2",
"vitepress": "1.0.0-alpha.34"
"vitepress": "^1.0.0-rc.10"
}
}
2 changes: 2 additions & 0 deletions packages/chat/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
tsconfig.tsbuildinfo
115 changes: 87 additions & 28 deletions packages/chat/client/chat.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<k-layout class="page-chat">
<template #header>
{{ header }}
{{ title }}
</template>

<template #left>
Expand All @@ -22,17 +22,31 @@
</el-scrollbar>
</template>

<template #right v-if="members[activeGuild]">
<virtual-list class="members" :data="members[activeGuild].data" pinned key-name="user.id">
<template #header>
<div ref="header" class="header-padding">
<div class="header-title">成员列表 ({{ members[activeGuild].next ? '加载中' : members[activeGuild].data.length }})</div>
</div>
</template>
<template #="data">
<member-view :data="data"></member-view>
</template>
<template #footer><div class="footer-padding"></div></template>
</virtual-list>
</template>

<keep-alive>
<template v-if="active" :key="active">
<virtual-list :data="messages[active]" pinned v-model:active-key="index" key-name="messageId">
<template #header><div class="header-padding"></div></template>
<template v-if="activeChannel" :key="activeChannel">
<virtual-list class="messages" :data="messages[activeChannel]" pinned v-model:activeChannel-key="index" key-name="messageId">
<template #header><div ref="header" class="header-padding"></div></template>
<template #="data">
<chat-message :successive="isSuccessive(data, data.index)" :data="data"></chat-message>
</template>
<template #footer><div class="footer-padding"></div></template>
</virtual-list>
<div class="card-footer">
<chat-input v-model="input" @send="handleSend"></chat-input>
<chat-input v-model="input" @send="handleSend" placeholder="向频道发送消息"></chat-input>
</div>
</template>
<template v-else>
Expand All @@ -46,18 +60,33 @@

<script lang="ts" setup>
import { ChatInput, Dict, send, store, VirtualList } from '@koishijs/client'
import { ChatInput, Dict, send, store, VirtualList, useContext } from '@koishijs/client'
import { computed, ref, watch } from 'vue'
import type { ChannelData, Message } from 'koishi-plugin-messages'
import { messages } from './utils'
import { useIntersectionObserver } from '@vueuse/core'
import type { Message, SyncChannel } from 'koishi-plugin-messages'
import {} from 'koishi-plugin-chat'
import { messages, members } from './utils'
import MemberView from './member.vue'
import ChatMessage from './message.vue'
const index = ref<string>()
const active = ref<string>('')
const activeChannel = ref<string>('')
const activeGuild = ref<string>('')
const tree = ref(null)
const header = ref(null)
const keyword = ref('')
const input = ref('')
const ctx = useContext()
ctx.action('chat.message.delete', {
action: ({ chat }) => {}, // deleteMessage(chat.message),
})
ctx.action('chat.message.quote', {
action: ({ chat }) => {}, // quote.value = chat.message,
})
watch(keyword, (val) => {
tree.value?.filter(val)
})
Expand All @@ -66,41 +95,41 @@ interface Tree {
id: string
label: string
children?: Tree[]
data?: ChannelData
data?: SyncChannel.Data
}
const data = computed(() => {
const data: Tree[] = []
const guilds: Dict<Tree> = {}
for (const key in store.chat) {
for (const key in store.chat.channels) {
const [platform, guildId, channelId] = key.split('/')
if (guildId === channelId) {
data.push({
id: key,
label: store.chat[key].channelName || '未知频道',
data: store.chat[key],
label: store.chat.channels[key].channelName || '未知频道',
data: store.chat.channels[key],
})
} else {
let guild = guilds[platform + '/' + guildId]
if (!guild) {
data.push(guild = guilds[platform + '/' + guildId] = {
id: platform + '/' + guildId,
label: store.chat[key].guildName || '未知群组',
label: store.chat.channels[key].guildName || '未知群组',
children: [],
})
}
guild.children!.push({
id: key,
label: store.chat[key].channelName || '未知频道',
data: store.chat[key],
label: store.chat.channels[key].channelName || '未知频道',
data: store.chat.channels[key],
})
}
}
return data
})
const header = computed(() => {
const channel = store.chat[active.value]
const title = computed(() => {
const channel = store.chat.channels[activeChannel.value]
if (!channel) return
if (channel.channelId === channel.guildId) {
return channel.channelName
Expand All @@ -115,7 +144,8 @@ function filterNode(value: string, data: Tree) {
function handleClick(tree: Tree) {
if (tree.children) return
active.value = tree.id
activeChannel.value = tree.id
activeGuild.value = tree.data!.guildId
const list = messages.value[tree.id] ||= []
if (list.length <= 100) {
send('chat/history', {
Expand All @@ -129,21 +159,42 @@ function handleClick(tree: Tree) {
function getClass(tree: Tree) {
const words: string[] = []
if (tree.id === active.value) words.push('is-active')
if (tree.id === activeChannel.value) words.push('is-activeChannel')
return words.join(' ')
}
function isSuccessive({ quoteId, userId, channelId }: Message, index: number) {
const prev = (messages.value[active.value] ||= [])[index - 1]
return !quoteId && !!prev && prev.userId === userId && prev.channelId === channelId
function isSuccessive({ quoteId, userId, channelId, username }: Message, index: number) {
const prev = (messages.value[activeChannel.value] ||= [])[index - 1]
return !quoteId && !!prev
&& prev.userId === userId
&& prev.channelId === channelId
&& prev.username === username
}
function handleSend(content: string) {
if (!active.value) return
const [platform, guildId, channelId] = active.value.split('/')
if (!activeChannel.value) return
const [platform, guildId, channelId] = activeChannel.value.split('/')
send('chat/send', { content, platform, channelId, guildId })
}
let task: Promise<void> = null
useIntersectionObserver(header, ([{ isIntersecting }]) => {
if (!isIntersecting || task) return
task = send('chat/history', {
platform: store.chat.channels[activeChannel.value].platform,
guildId: store.chat.channels[activeChannel.value].guildId,
channelId: store.chat.channels[activeChannel.value].channelId,
id: messages.value[activeChannel.value][0]?.id,
})
task.then(() => task = null)
})
watch(() => store.chat.channels[activeChannel.value]?.guildId, async (guildId) => {
if (!guildId) return
members.value[guildId] = await send('chat/members', store.chat.channels[activeChannel.value].platform, guildId)
})
</script>

<style lang="scss">
Expand All @@ -162,13 +213,21 @@ function handleSend(content: string) {
flex-direction: column;
}
.header-padding, .footer-padding {
padding: 0.25rem 0;
.messages {
.header-padding, .footer-padding {
padding: 0.25rem 0;
}
}
.members {
.header-padding, .footer-padding {
padding: 0.5rem 1rem;
}
}
.card-footer {
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
border-top: 1px solid var(--k-color-border);
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/chat/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@ export default (ctx: Context) => {
component: Chat,
order: 100,
})

ctx.menu('chat.message', [{
id: '.delete',
label: '删除消息',
}, {
id: '.quote',
label: '引用回复',
}])
}
56 changes: 56 additions & 0 deletions packages/chat/client/member.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<template>
<div class="member-view">
<div class="left">
<img v-if="avatar" class="avatar" :src="avatar"/>
</div>
<div class="right">
{{ name }}
</div>
</div>
</template>

<script setup lang="ts">
import type { Universal } from 'koishi'
import { computed } from 'vue'
const props = defineProps<{
data: Universal.GuildMember
}>()
const avatar = computed(() => props.data.avatar || props.data.user.avatar)
const name = computed(() => props.data.name || props.data.user.name)
</script>

<style lang="scss" scoped>
$avatar-size: 2rem;
.member-view {
display: flex;
padding: 0.5rem 1rem;
height: $avatar-size;
overflow: hidden;
align-items: center;
gap: 0 1rem;
user-select: none;
}
.left, .right {
display: flex;
flex-direction: column;
align-items: center;
}
.left {
width: $avatar-size;
}
.avatar {
height: $avatar-size;
width: $avatar-size;
border-radius: 100%;
}
</style>
20 changes: 14 additions & 6 deletions packages/chat/client/message.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="chat-message" :class="{ successive }">
<div class="chat-message" :class="{ successive }" @contextmenu.stop="trigger($event, data)">
<div class="quote" v-if="data.quote" @click="$emit('locate', data.quote.messageId)">
<img class="quote-avatar" v-if="data.quote.author.avatar" :src="data.quote.author.avatar"/>
<span class="username">{{ data.quote.author.username }}</span>
Expand All @@ -25,8 +25,8 @@

<script lang="ts" setup>
import { Message } from '../src'
import { MessageContent, ChatImage } from '@koishijs/client'
import { Message } from 'koishi-plugin-messages'
import { MessageContent, ChatImage, useMenu } from '@koishijs/client'
defineEmits(['locate'])
Expand All @@ -35,6 +35,8 @@ defineProps<{
successive: boolean
}>()
const trigger = useMenu('chat.message')
function formatAbstract(content: string) {
if (content.length < 50) return content
return content.slice(0, 48) + '……'
Expand Down Expand Up @@ -62,11 +64,13 @@ $padding: $avatarSize + 1rem;
.chat-message {
position: relative;
padding: 0 1.5rem;
padding: 0 1rem;
word-break: break-word;
--k-hover-bg: var(--bg2);
&:hover {
background-color: var(--hover-bg);;
background-color: var(--k-hover-bg);
}
&:not(.successive) {
Expand All @@ -82,9 +86,13 @@ $padding: $avatarSize + 1rem;
position: absolute;
visibility: hidden;
left: 0;
top: 0;
height: 100%;
width: $padding + 1rem;
text-align: center;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
&:hover {
Expand Down
Loading

0 comments on commit e49ccc0

Please sign in to comment.