Skip to content

Commit e49ccc0

Browse files
authored
feat(chat): implement chat (#5)
1 parent dfbc366 commit e49ccc0

32 files changed

+6070
-149
lines changed

package.json

+13-13
Original file line numberDiff line numberDiff line change
@@ -25,34 +25,34 @@
2525
"test:html": "shx rm -rf coverage && c8 -r html yarn test"
2626
},
2727
"devDependencies": {
28-
"@koishijs/eslint-config": "^1.0.0",
29-
"@koishijs/plugin-database-memory": "^2.3.1",
30-
"@koishijs/plugin-mock": "^2.4.3",
31-
"@koishijs/vitepress": "^1.6.5",
28+
"@koishijs/eslint-config": "^1.0.4",
29+
"@koishijs/plugin-database-memory": "^2.3.6",
30+
"@koishijs/plugin-mock": "^2.6.3",
31+
"@koishijs/vitepress": "^3.0.1",
3232
"@sinonjs/fake-timers": "^6.0.1",
3333
"@types/mocha": "^9.1.1",
34-
"@types/node": "^18.15.3",
34+
"@types/node": "^20.4.2",
3535
"@types/sinonjs__fake-timers": "^6.0.4",
36-
"c8": "^7.13.0",
37-
"esbuild": "^0.17.12",
36+
"c8": "^8.0.1",
37+
"esbuild": "^0.19.2",
3838
"esbuild-register": "^3.4.2",
39-
"eslint": "^8.36.0",
39+
"eslint": "^8.45.0",
4040
"eslint-plugin-mocha": "^10.1.0",
4141
"jest-mock": "^28.1.3",
42-
"mocha": "^9.2.2",
42+
"mocha": "^10.2.0",
4343
"sass": "^1.59.3",
4444
"shx": "^0.3.4",
45-
"typescript": "^4.9.5",
45+
"typescript": "^5.2.2",
4646
"yml-register": "^1.1.0",
4747
"yakumo": "^0.3.9",
48-
"yakumo-esbuild": "^0.3.22",
48+
"yakumo-esbuild": "^0.3.26",
4949
"yakumo-esbuild-yaml": "^0.3.1",
5050
"yakumo-mocha": "^0.3.1",
5151
"yakumo-publish": "^0.3.3",
5252
"yakumo-publish-sync": "^0.3.2",
5353
"yakumo-tsc": "^0.3.7",
54-
"yakumo-upgrade": "^0.3.2",
54+
"yakumo-upgrade": "^0.3.4",
5555
"yakumo-version": "^0.3.2",
56-
"vitepress": "1.0.0-alpha.34"
56+
"vitepress": "^1.0.0-rc.10"
5757
}
5858
}

packages/chat/.npmignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.DS_Store
2+
tsconfig.tsbuildinfo

packages/chat/client/chat.vue

+87-28
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<k-layout class="page-chat">
33
<template #header>
4-
{{ header }}
4+
{{ title }}
55
</template>
66

77
<template #left>
@@ -22,17 +22,31 @@
2222
</el-scrollbar>
2323
</template>
2424

25+
<template #right v-if="members[activeGuild]">
26+
<virtual-list class="members" :data="members[activeGuild].data" pinned key-name="user.id">
27+
<template #header>
28+
<div ref="header" class="header-padding">
29+
<div class="header-title">成员列表 ({{ members[activeGuild].next ? '加载中' : members[activeGuild].data.length }})</div>
30+
</div>
31+
</template>
32+
<template #="data">
33+
<member-view :data="data"></member-view>
34+
</template>
35+
<template #footer><div class="footer-padding"></div></template>
36+
</virtual-list>
37+
</template>
38+
2539
<keep-alive>
26-
<template v-if="active" :key="active">
27-
<virtual-list :data="messages[active]" pinned v-model:active-key="index" key-name="messageId">
28-
<template #header><div class="header-padding"></div></template>
40+
<template v-if="activeChannel" :key="activeChannel">
41+
<virtual-list class="messages" :data="messages[activeChannel]" pinned v-model:activeChannel-key="index" key-name="messageId">
42+
<template #header><div ref="header" class="header-padding"></div></template>
2943
<template #="data">
3044
<chat-message :successive="isSuccessive(data, data.index)" :data="data"></chat-message>
3145
</template>
3246
<template #footer><div class="footer-padding"></div></template>
3347
</virtual-list>
3448
<div class="card-footer">
35-
<chat-input v-model="input" @send="handleSend"></chat-input>
49+
<chat-input v-model="input" @send="handleSend" placeholder="向频道发送消息"></chat-input>
3650
</div>
3751
</template>
3852
<template v-else>
@@ -46,18 +60,33 @@
4660

4761
<script lang="ts" setup>
4862
49-
import { ChatInput, Dict, send, store, VirtualList } from '@koishijs/client'
63+
import { ChatInput, Dict, send, store, VirtualList, useContext } from '@koishijs/client'
5064
import { computed, ref, watch } from 'vue'
51-
import type { ChannelData, Message } from 'koishi-plugin-messages'
52-
import { messages } from './utils'
65+
import { useIntersectionObserver } from '@vueuse/core'
66+
import type { Message, SyncChannel } from 'koishi-plugin-messages'
67+
import {} from 'koishi-plugin-chat'
68+
import { messages, members } from './utils'
69+
import MemberView from './member.vue'
5370
import ChatMessage from './message.vue'
5471
5572
const index = ref<string>()
56-
const active = ref<string>('')
73+
const activeChannel = ref<string>('')
74+
const activeGuild = ref<string>('')
5775
const tree = ref(null)
76+
const header = ref(null)
5877
const keyword = ref('')
5978
const input = ref('')
6079
80+
const ctx = useContext()
81+
82+
ctx.action('chat.message.delete', {
83+
action: ({ chat }) => {}, // deleteMessage(chat.message),
84+
})
85+
86+
ctx.action('chat.message.quote', {
87+
action: ({ chat }) => {}, // quote.value = chat.message,
88+
})
89+
6190
watch(keyword, (val) => {
6291
tree.value?.filter(val)
6392
})
@@ -66,41 +95,41 @@ interface Tree {
6695
id: string
6796
label: string
6897
children?: Tree[]
69-
data?: ChannelData
98+
data?: SyncChannel.Data
7099
}
71100
72101
const data = computed(() => {
73102
const data: Tree[] = []
74103
const guilds: Dict<Tree> = {}
75-
for (const key in store.chat) {
104+
for (const key in store.chat.channels) {
76105
const [platform, guildId, channelId] = key.split('/')
77106
if (guildId === channelId) {
78107
data.push({
79108
id: key,
80-
label: store.chat[key].channelName || '未知频道',
81-
data: store.chat[key],
109+
label: store.chat.channels[key].channelName || '未知频道',
110+
data: store.chat.channels[key],
82111
})
83112
} else {
84113
let guild = guilds[platform + '/' + guildId]
85114
if (!guild) {
86115
data.push(guild = guilds[platform + '/' + guildId] = {
87116
id: platform + '/' + guildId,
88-
label: store.chat[key].guildName || '未知群组',
117+
label: store.chat.channels[key].guildName || '未知群组',
89118
children: [],
90119
})
91120
}
92121
guild.children!.push({
93122
id: key,
94-
label: store.chat[key].channelName || '未知频道',
95-
data: store.chat[key],
123+
label: store.chat.channels[key].channelName || '未知频道',
124+
data: store.chat.channels[key],
96125
})
97126
}
98127
}
99128
return data
100129
})
101130
102-
const header = computed(() => {
103-
const channel = store.chat[active.value]
131+
const title = computed(() => {
132+
const channel = store.chat.channels[activeChannel.value]
104133
if (!channel) return
105134
if (channel.channelId === channel.guildId) {
106135
return channel.channelName
@@ -115,7 +144,8 @@ function filterNode(value: string, data: Tree) {
115144
116145
function handleClick(tree: Tree) {
117146
if (tree.children) return
118-
active.value = tree.id
147+
activeChannel.value = tree.id
148+
activeGuild.value = tree.data!.guildId
119149
const list = messages.value[tree.id] ||= []
120150
if (list.length <= 100) {
121151
send('chat/history', {
@@ -129,21 +159,42 @@ function handleClick(tree: Tree) {
129159
130160
function getClass(tree: Tree) {
131161
const words: string[] = []
132-
if (tree.id === active.value) words.push('is-active')
162+
if (tree.id === activeChannel.value) words.push('is-activeChannel')
133163
return words.join(' ')
134164
}
135165
136-
function isSuccessive({ quoteId, userId, channelId }: Message, index: number) {
137-
const prev = (messages.value[active.value] ||= [])[index - 1]
138-
return !quoteId && !!prev && prev.userId === userId && prev.channelId === channelId
166+
function isSuccessive({ quoteId, userId, channelId, username }: Message, index: number) {
167+
const prev = (messages.value[activeChannel.value] ||= [])[index - 1]
168+
return !quoteId && !!prev
169+
&& prev.userId === userId
170+
&& prev.channelId === channelId
171+
&& prev.username === username
139172
}
140173
141174
function handleSend(content: string) {
142-
if (!active.value) return
143-
const [platform, guildId, channelId] = active.value.split('/')
175+
if (!activeChannel.value) return
176+
const [platform, guildId, channelId] = activeChannel.value.split('/')
144177
send('chat/send', { content, platform, channelId, guildId })
145178
}
146179
180+
let task: Promise<void> = null
181+
182+
useIntersectionObserver(header, ([{ isIntersecting }]) => {
183+
if (!isIntersecting || task) return
184+
task = send('chat/history', {
185+
platform: store.chat.channels[activeChannel.value].platform,
186+
guildId: store.chat.channels[activeChannel.value].guildId,
187+
channelId: store.chat.channels[activeChannel.value].channelId,
188+
id: messages.value[activeChannel.value][0]?.id,
189+
})
190+
task.then(() => task = null)
191+
})
192+
193+
watch(() => store.chat.channels[activeChannel.value]?.guildId, async (guildId) => {
194+
if (!guildId) return
195+
members.value[guildId] = await send('chat/members', store.chat.channels[activeChannel.value].platform, guildId)
196+
})
197+
147198
</script>
148199

149200
<style lang="scss">
@@ -162,13 +213,21 @@ function handleSend(content: string) {
162213
flex-direction: column;
163214
}
164215
165-
.header-padding, .footer-padding {
166-
padding: 0.25rem 0;
216+
.messages {
217+
.header-padding, .footer-padding {
218+
padding: 0.25rem 0;
219+
}
220+
}
221+
222+
.members {
223+
.header-padding, .footer-padding {
224+
padding: 0.5rem 1rem;
225+
}
167226
}
168227
169228
.card-footer {
170229
padding: 1rem 1.25rem;
171-
border-top: 1px solid var(--border);
230+
border-top: 1px solid var(--k-color-border);
172231
}
173232
}
174233

packages/chat/client/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,12 @@ export default (ctx: Context) => {
1212
component: Chat,
1313
order: 100,
1414
})
15+
16+
ctx.menu('chat.message', [{
17+
id: '.delete',
18+
label: '删除消息',
19+
}, {
20+
id: '.quote',
21+
label: '引用回复',
22+
}])
1523
}

packages/chat/client/member.vue

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<template>
2+
<div class="member-view">
3+
<div class="left">
4+
<img v-if="avatar" class="avatar" :src="avatar"/>
5+
</div>
6+
<div class="right">
7+
{{ name }}
8+
</div>
9+
</div>
10+
</template>
11+
12+
<script setup lang="ts">
13+
14+
import type { Universal } from 'koishi'
15+
import { computed } from 'vue'
16+
17+
const props = defineProps<{
18+
data: Universal.GuildMember
19+
}>()
20+
21+
const avatar = computed(() => props.data.avatar || props.data.user.avatar)
22+
const name = computed(() => props.data.name || props.data.user.name)
23+
24+
</script>
25+
26+
<style lang="scss" scoped>
27+
28+
$avatar-size: 2rem;
29+
30+
.member-view {
31+
display: flex;
32+
padding: 0.5rem 1rem;
33+
height: $avatar-size;
34+
overflow: hidden;
35+
align-items: center;
36+
gap: 0 1rem;
37+
user-select: none;
38+
}
39+
40+
.left, .right {
41+
display: flex;
42+
flex-direction: column;
43+
align-items: center;
44+
}
45+
46+
.left {
47+
width: $avatar-size;
48+
}
49+
50+
.avatar {
51+
height: $avatar-size;
52+
width: $avatar-size;
53+
border-radius: 100%;
54+
}
55+
56+
</style>

packages/chat/client/message.vue

+14-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="chat-message" :class="{ successive }">
2+
<div class="chat-message" :class="{ successive }" @contextmenu.stop="trigger($event, data)">
33
<div class="quote" v-if="data.quote" @click="$emit('locate', data.quote.messageId)">
44
<img class="quote-avatar" v-if="data.quote.author.avatar" :src="data.quote.author.avatar"/>
55
<span class="username">{{ data.quote.author.username }}</span>
@@ -25,8 +25,8 @@
2525

2626
<script lang="ts" setup>
2727
28-
import { Message } from '../src'
29-
import { MessageContent, ChatImage } from '@koishijs/client'
28+
import { Message } from 'koishi-plugin-messages'
29+
import { MessageContent, ChatImage, useMenu } from '@koishijs/client'
3030
3131
defineEmits(['locate'])
3232
@@ -35,6 +35,8 @@ defineProps<{
3535
successive: boolean
3636
}>()
3737
38+
const trigger = useMenu('chat.message')
39+
3840
function formatAbstract(content: string) {
3941
if (content.length < 50) return content
4042
return content.slice(0, 48) + '……'
@@ -62,11 +64,13 @@ $padding: $avatarSize + 1rem;
6264
6365
.chat-message {
6466
position: relative;
65-
padding: 0 1.5rem;
67+
padding: 0 1rem;
6668
word-break: break-word;
6769
70+
--k-hover-bg: var(--bg2);
71+
6872
&:hover {
69-
background-color: var(--hover-bg);;
73+
background-color: var(--k-hover-bg);
7074
}
7175
7276
&:not(.successive) {
@@ -82,9 +86,13 @@ $padding: $avatarSize + 1rem;
8286
position: absolute;
8387
visibility: hidden;
8488
left: 0;
89+
top: 0;
90+
height: 100%;
8591
width: $padding + 1rem;
86-
text-align: center;
8792
user-select: none;
93+
display: inline-flex;
94+
align-items: center;
95+
justify-content: center;
8896
}
8997
9098
&:hover {

0 commit comments

Comments
 (0)