近期在期末考,所以更新会比较慢,这篇文章主要讲解如何实现消息发送功能。

客户端的私聊界面

之前的文章中我都没有去讲解客户端的代码。在讲解消息发送之前,我先介绍一下客户端的代码。

我的客户端中有一个PrivateMessageChatPage组件,用来显示和某个用户的私聊界面。这是最终的效果:

PrivateMessageChatPage的布局是这样的:

PrivateMessageChatPage

目前只有PrivateMessageMessagesWrapperPrivateMessageTextBox组件是有内容的,其他的组件都是空的。不过这不影响我们的消息显示。

首先导入这些组件和一些类型:

1
2
3
4
5
6
7
8
import React, { useState } from 'react'
import PrivateMessageTabBar from './Private_Message_Tab_Bar'
import PrivateMessageMessagesWrapper from './Private_Message_Messages_Wrapper'
import PrivateMessageProfilePanel from './Private_Message_Profile_Panel'
import PrivateMessageTextBox from './Private_Message_Text_Box'
import FriendsListSideBar from '../private_message_common/Friends_List_Sidebar'
import { Message } from '../../types/interfaces'
import { MessageContext } from '../context/Message_Context'

Message类型:

1
2
3
4
5
6
7
export interface Message {
id?: string
senderId: string
receiverId: string
text: string
timestamp: string
}

每条消息都有一个senderIdreceiverIdtexttimestampid是消息的唯一标识符。

我们之后会根据senderIdreceiverId来从服务端获取消息。

PrivateMessageChatPage组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const PrivateMessageChatPage = () => {
const [receiverName, setReceiverName] = useState<string>('Dummy')
const [newMessage, setNewMessage] = useState<Message | null>(null)

const addMessage = (message: Message) => {
setNewMessage(message)
}

return (
<MessageContext.Provider value={{ newMessage, addMessage }}>
<FriendsListSideBar />
<div className="d-flex flex-column mx-0 h-100 w-100">
<div
className="d-flex flex-row"
style={{ height: '48px', padding: '8px', fontSize: '16px', borderBottom: 'solid 3px rgba(45, 47, 52)' }}>
<PrivateMessageTabBar receiverUsername={receiverName} setReceiverUsername={setReceiverName} />
</div>
<div className="d-flex flex-row flex-fill align-items-stretch p-0">
<div
className="d-flex flex-column h-100 position-relative"
style={{ minWidth: 0, minHeight: 0, flex: '1 1 auto' }}>
<div className="position-relative" style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, zIndex: 0 }}>
<PrivateMessageMessagesWrapper receiverUsername={receiverName} />
</div>
<div className="position-sticky bottom-0 w-100" style={{ backgroundColor: 'rgba(49, 51, 56)' }}>
<PrivateMessageTextBox receiverUsername={receiverName} addMessage={addMessage} />
</div>
</div>
<PrivateMessageProfilePanel />
</div>
</div>
</MessageContext.Provider>
)
}

export default PrivateMessageChatPage

MessageContext是一个React上下文,用来传递消息。

React的Context API是一种在组件之间共享数据的方法,而不必通过组件树的逐层传递props

通过创建一个Context对象,然后使用<MyContext.Provider>组件将值传递给后代组件,可以在组件树中传递数据。

1
2
3
4
5
6
7
8
9
import React from 'react'
import { Message } from '../../types/interfaces'

interface MessageContextType {
newMessage: Message | null
addMessage: (message: Message) => void
}

export const MessageContext = React.createContext<MessageContextType | undefined>(undefined)

在这个例子中,MessageContext被用来在组件树中共享newMessageaddMessage

  • newMessage是最新的消息。
  • addMessage是一个函数,用来添加消息。

我们的PrivateMessageTextBox组件是用来发送消息的,当用户在输入框中输入新的消息并发送时,组件会调用addMessage方法,将新消息添加到MessageContext中。而PrivateMessageMessagesWrapper组件会监听newMessage的改变,一旦newMessage出现了变化,新消息就会被添加到该组件的状态中用于显示。

这意味着,用户只要发送了消息,消息就会立即显示在界面上。

PrivateMessageTextBox组件的handleSendMessage方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const PrivateMessageTextBox: React.FC<PrivateMessageTextBoxProps> = ({ receiverUsername }) => {
const [message, setMessage] = useState<string>('')
const context = useContext(MessageContext)
if (!context) {
throw new Error('MessageContext is undefined')
}
const {addMessage} = context

const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
}

const handleSendMessage = async (e?: FormEvent) => {
e && e.preventDefault()

const jwtToken = localStorage.getItem('jwtToken')
const senderId = localStorage.getItem('userId')
if (!senderId) {
throw new Error('User ID not found in local storage')
}
const response = await UserService.getUserByUsername(jwtToken, receiverUsername)
const receiver = response.data

const privateMessage = {
id: `${socket.id}${Math.random()}`,
senderId: senderId,
receiverId: receiver._id,
text: message,
timestamp: new Date().toISOString(),
}

socket.emit('privateMessageSent', privateMessage)
addMessage(privateMessage)
setMessage('')
}
}

这里的UserService.getUserByUsername方法进行了更改,需要多传入一个jwtToken参数。

1
2
3
4
5
6
7
8
9
export const UserService = {
getUserByUsername: (token: string | null, username: string) => {
return api.get(`/users/username/${username}`, { headers: { Authorization: `Bearer ${token}` } })
},

getUserByUserId: (token: string | null, userId: string) => {
return api.get(`/users/userid/${userId}`, { headers: { Authorization: `Bearer ${token}` } })
},
}

这是因为在上个文章中我实现了@Public装饰器,用来标记哪些路由是公开的。

根据用户的用户名或者ID来获取用户信息,都应当是私有的,所以需要传入jwtToken

OAuth 2.0授权框架规范中定义了Bearer令牌类型,它是一种用于OAuth 2.0的访问令牌,用于对资源进行身份验证。任何持有Bearer令牌的人都可以访问与该令牌相关联的资源。

向服务端发送privateMessageSent事件后,立即调用addMessage方法,将消息添加到MessageContext中。

消息传递

如果只是写了客户端的代码,那么消息只是在客户端显示,而不会真正的发送到服务端。要实现消息发送,我们需要在服务端中接收消息。

上个文章的最后一个分段中,我实现了Socket模块。

Socket用白话来说就是一个通道,客户端和服务端可以通过这个通道进行双向通信。双向通信和传统的HTTP请求不同,HTTP请求是单向的,客户端向服务端发送请求、服务端返回响应。而Socket是双向的,客户端和服务端可以随时向对方发送消息。

上个文章中SocketGateway(网关)订阅了privateMessageSent事件,但我只是简单的打印了一下消息,并没有去更进一步的处理。

1
2
3
4
@SubscribeMessage('privateMessageSent')
handlePrivateMessage(@MessageBody() data: any, client: Socket) {
console.log('Received private message:', data, 'from', client)
}

这次我要详细地讲解如何实现消息传递。首先消息传递在技术上的流程是这样的:

  1. 客户端使用Socket向服务端发送privateMessageSent事件。
  2. 服务端在SocketGateway中接收到privateMessageSent事件后,依靠MessagesService将消息存储到数据库中。

为什么要细分出两个模块呢?因为我认为这两个模块的职责不同,Socket模块负责处理Socket相关的逻辑,Messages模块则负责处理消息的存储与获取。

上个文章中并没有细写Messages模块,我现在来写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { Message, MessageSchema } from './message.schema'
import { MessagesService } from './messages.service'
import { MessagesController } from './messages.controller'

@Module({
imports: [MongooseModule.forFeature([{ name: Message.name, schema: MessageSchema }])],
providers: [MessagesService],
controllers: [MessagesController],
exports: [MessagesService],
})
export class MessagesModule {}

exports是用来导出MessagesService的,这样其他模块就可以使用MessagesService了。刚才也说过,SocketGateway需要使用MessagesService来存储消息。

我在这里做了一些修改,将MessagesSchema更改为了MessageSchema。因为这个模型实际上是用来存储单一的消息,所以我认为它的名字应该是单数形式。同时,我将原先的senderreceiver更改为了senderIdreceiverId,因为我想通过用户的ID来查找用户,而不是直接使用用户对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'

@Schema()
export class Message extends Document {
@Prop({ required: true })
senderId: string

@Prop({ required: true })
receiverId: string

@Prop({ required: true })
text: string

@Prop({ default: Date.now })
timestamp: Date
}

export const MessageSchema = SchemaFactory.createForClass(Message)

在这里,我添加了一个timestamp字段,用来记录每条消息的发送时间。

在客户端里,我将发送给服务端的消息数据结构设计为以下形式:

1
2
3
4
5
6
7
{
id: `${socket.id}${Math.random()}`,
senderId: senderId,
receiverId: receiver._id,
text: message,
timestamp: new Date().toISOString(),
}

这里,我也添加了一个timestamp字段。这是因为我希望客户端在发送消息后,能立即在界面上显示这条消息,而不需要等待服务端的响应。

值得注意的是,我选择让客户端直接显示消息,而没有等待服务端存储消息并返回。这样做的结果是,客户端显示的时间戳实际上是客户端发送消息的时间,而不是服务端存储消息的时间,除非用户刷新了页面,让客户端向服务端请求实际的数据。

接着我们要在MessagesService中添加一个方法,用来存储消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Message } from './message.schema'
import { CreateMessageDto } from './dto/create-message.dto'

@Injectable()
export class MessagesService {
constructor(@InjectModel(Message.name) private messageModel: Model<Message>) {}

async create(createMessageDto: CreateMessageDto): Promise<Message> {
const createdMessage = new this.messageModel(createMessageDto)
return await createdMessage
.save()
.then(async (message) => {
console.log('Message saved:', message)
return message
})
.catch((error: any) => {
console.log('Error saving message:', error)
throw error
})
}
}

CreateMessageDto是一个数据传输对象,用来传输消息的数据。

1
2
3
4
5
export class CreateMessageDto {
readonly text: string
readonly senderId: string
readonly receiverId: string
}

在这个方法中,我只需要textsenderIdreceiverId这三个字段。

接着使用save方法来保存消息,如果保存成功则返回消息,否则抛出错误。

最后就是在SocketGateway中调用MessagesServicecreate方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { MessagesService } from '../messages/messages.service'

// ...

export class SocketGateway implements OnGatewayInit {
constructor(
// ...
private messagesService: MessagesService,
) {}

@SubscribeMessage('privateMessageSent')
async handlePrivateMessage(@MessageBody() data: any): Promise<void> {
await this.messagesService.create({ senderId: data.senderId, receiverId: data.receiverId, text: data.text })
}
}

别忘了SocketModule中要导入MessagesModule,才能让SocketGateway使用MessagesService

1
2
3
4
5
6
7
8
9
10
import { Module } from '@nestjs/common'
import { SocketService } from './socket.service'
import { SocketGateway } from './socket.gateway'
import { MessagesModule } from '../messages/messages.module'

@Module({
imports: [MessagesModule],
providers: [SocketGateway, SocketService],
})
export class SocketModule {}

这样服务端就可以接收到客户端发送的消息,并将消息存储到数据库中。

消息显示

用户点击他们和其他用户的私聊界面时,我们需要从服务端获取他们之间的所有消息。

在我的应用中,因为是仿照Discord的,所有的私聊路由都是/channel/@me/:id这种形式的。id是接收者的ID。

也就是说我们可以让服务端有一个GET路由,当客户端访问这个路由时,服务端会返回当前用户和id用户之间的所有消息。

MessagesController中添加GET路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Controller, Get, Param, Request } from '@nestjs/common'
import { Request as ExpressRequest } from 'express'
import { MessagesService } from './messages.service'

@Controller('messages')
export class MessagesController {
constructor(private readonly messagesService: MessagesService) {}

@Get(':senderId/:receiverId')
async getMessages(
@Request() req: ExpressRequest,
@Param('senderId') senderId: string,
@Param('receiverId') receiverId: string,
) {
return await this.messagesService.getMessages(senderId, receiverId)
}
}

当客户端访问/api/messages/:senderId/:receiverId时,服务端会返回当前用户(senderId)和receiverId用户之间的所有消息。

MessagesService中添加getMessages方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async getMessages(senderId: string, receiverId: string) {
return this.messageModel
.find({
senderId: senderId,
receiverId: receiverId,
})
.exec()
.then((messages) => {
console.log('Messages found:', messages)
return messages
})
.catch((error: any) => {
console.log('Error finding messages:', error)
throw error
})
}

客户端里也添加一个方法,专门访问/api/messages/:receiverId

1
2
3
4
5
export const MessageService = {
getMessagesByUserId: (token: string | null, senderId: string | null, receiverId: string) => {
return api.get(`/messages/${senderId}/${receiverId}`, { headers: { Authorization: `Bearer ${token}` } })
},
}

现在回到客户端的PrivateMessageMessagesWrapper组件。我们需要明白这个组件的职责是什么:

  1. 当用户点击某个用户的私聊界面时,组件会向服务端请求senderId用户和receiverId用户之间的所有消息。
  2. 当用户发送消息时,组件会将新消息添加到消息列表中来立即显示。

首先以最基础的形式来写这个组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
const PrivateMessageMessagesWrapper: React.FC<PrivateMessageMessagesWrapperProps> = ({ receiverUsername }) => {
const [messages, setMessages] = useState<Message[]>([])
const currentUser = useSelector((state: { auth: { user: UserProfile } }) => state.auth.user)

return (
<div
className="d-flex flex-column position-absolute top-0 bottom-0 overflow-y-scroll overflow-x-hidden"
style={{ left: 0, right: 0, overflowAnchor: 'none' }}>
<ol className="p-0 m-0" style={{ flex: 1, minHeight: '0', listStyle: 'none' }}>
{messages.map((message, index) => (
<li key={message.id || index} className="position-relative mx-2" style={{ outline: 'none' }}>
<div
className="position-relative"
style={{
marginTop: '1.0625rem',
minHeight: '2.75rem',
paddingTop: '0.125rem',
paddingBottom: '0.125rem',
paddingLeft: '72px',
paddingRight: '48px!important',
wordWrap: 'break-word',
userSelect: 'text',
}}>
<div className="position-static ms-0 ps-0" style={{ textIndent: 'none' }}>
{/* User's avatar */}
<img
src={imgURL}
className="position-absolute overflow-hidden"
style={{
pointerEvents: 'auto',
textIndent: '-9999px',
left: '16px',
marginTop: 'calc(4px-0.125rem)',
width: '40px',
height: '40px',
borderRadius: '50%',
cursor: 'pointer',
userSelect: 'none',
}}
alt=""
/>

{/* Username and message time */}
<h3
className="overflow-hidden position-relative p-0 m-0 d-flex flex-row align-items-center"
style={{
display: 'block',
lineHeight: '1.375rem',
minHeight: '1.375rem',
whiteSpace: 'break-spaces',
}}>
<span
className="me-1 fs-6 position-relative overflow-hidden text-white"
style={{
fontWeight: '500',
display: 'inline',
verticalAlign: 'baseline',
outline: 'none',
}}>
{message.senderId === currentUser._id ? currentUser.username : receiverUsername}
</span>
<span
className="ms-1"
style={{
fontSize: '0.75rem',
height: '1.25rem',
verticalAlign: 'baseline',
display: 'inline-block',
cursor: 'default',
pointerEvents: 'none',
outline: 'none',
fontWeight: '500',
color: 'rgba(148, 154, 158)',
}}>
<time dateTime={message.timestamp.toString()}>{formatDate(message.timestamp)}</time>
</span>
</h3>
</div>

{/* Message Content */}
<div
className="overflow-hidden position-relative fs-6 p-0 m-0"
style={{
userSelect: 'text',
whiteSpace: 'break-spaces',
wordWrap: 'break-word',
marginLeft: 'calc(-1 * 72px)',
paddingLeft: '72px',
textIndent: '0',
lineHeight: '1.375rem',
color: 'rgba(219, 222, 225)',
}}>
<span>{message.text}</span>
</div>
</div>
</li>
))}
</ol>
</div>
)
}
  • messages是该组件的状态,用来存储所有的需要被显示的消息。消息的数据结构是Message,上面已经定义过了。
  • currentUser是从Redux中提取出的当前用户的信息,包括了用户的ID和用户名。

这个组件的底层逻辑是这样的:

  1. map遍历messages数组,对每一条消息都生成一个li元素。
  2. 每一条消息都包含了用户的头像(这里写死了)、用户名(如果消息的senderId和当前用户的ID相同,那么消息的用户名就是当前用户的用户名,否则就是接收者的用户名)、消息发送时间和消息内容。

消息发送时间是通过formatDate函数格式化的:

1
2
3
4
5
6
7
8
9
10
export const formatDate = (timestamp: string) => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')

return `${year}/${month}/${day} ${hours}:${minutes}`
}

那么我们该如何获取消息、并将消息添加到messages中呢?

PrivateMessageMessagesWrapper组件被挂载时,以及receiverUsername发生变化时,我们都需要向服务端请求消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
useEffect(() => {
const fetchMessages = async () => {
const jwtToken = localStorage.getItem('jwtToken')
const senderId = localStorage.getItem('userId')
const response = await UserService.getUserByUsername(jwtToken, receiverUsername)
const receiver = response.data

try {
const res = await MessageService.getMessagesByUserId(jwtToken, senderId, receiver._id)
setMessages(res.data)
return res.data
} catch (err) {
console.error(err)
}
}

fetchMessages().then((r) => console.log('Messages fetched:', r))
}, [receiverUsername])

这里我使用了useEffect钩子,当receiverUsername发生变化时,就会调用fetchMessages方法。

fetchMessages方法会向服务端请求senderId用户和receiverId用户之间的所有消息,并将消息存储到messages中。

不只是如此,先前我们在PrivateMessageTextBox组件中发送消息时,会将用户自身发送的消息添加到MessageContext中。PrivateMessageMessagesWrapper组件同样也需要去监听用户自身发送的消息,并将这些消息添加到messages中:

1
2
3
4
5
6
7
8
9
10
11
const context = useContext(MessageContext)
if (!context) {
throw new Error('MessageContext is undefined')
}
const { newMessage } = context

useEffect(() => {
if (newMessage) {
setMessages((prevMessages) => [...prevMessages, newMessage])
}
}, [newMessage])

滚动到底部

当该被显示的消息超过了可视区域时,用户需要手动滚动到底部才能看到最新的消息。这是不友好的,我们应当让用户看到最新的消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const endOfMessagesRef = useRef<null | HTMLSpanElement>(null)
const prevMessagesLength = useRef<number>(0)

useEffect(() => {
if (endOfMessagesRef.current && messages.length > prevMessagesLength.current) {
endOfMessagesRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
}
prevMessagesLength.current = messages.length
}, [messages])

return (
<div>
<ol>
{messages.map((message, index) => (
// ...
))}
<span ref={endOfMessagesRef} />
</ol>
</div>
)

我使用了useRef来创建一个endOfMessagesRef引用,用来指向消息列表的最底部。当messages数组的长度发生变化时,我就将endOfMessagesRef滚动到可视区域。

prevMessagesLength也是一个useRef,用来存储上一次messages的长度。这个长度在回调函数的最后会被更新为当前的messages的长度。

useEffect的回调函数会先检查endOfMessagesRef.current是否存在,以及messages的长度是否大于prevMessagesLength.current。如果两个条件都满足,就将endOfMessagesRef.current滚动到可视区域。

scrollIntoView方法是一个DOM方法,用来将元素滚动到可视区域。

  • behavior决定了滚动的动画效果,smooth表示平滑滚动。
  • block决定了元素在垂直方向上的对齐方式,nearest表示将元素对齐到最接近的边缘。
  • inline决定了元素在水平方向上的对齐方式,start表示将元素对齐到起始边缘。

span标签是一个空元素,用来占位,需要放在ol标签的最后一个子元素后面。

浏览器刷新后状态丢失

在用户刷新浏览器后,Redux的状态会丢失。这是因为Redux的状态是存储在内存中的,刷新浏览器后内存被清空,状态也就丢失了。

由于我们已经在localStorage中存储了用户的jwtTokenuserId,我们可以从localStorage中获取这些信息,并重新设置Redux的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useAppDispatch } from './redux/store'
import { setUserDetails } from './redux/actions/authActions'

function App() {
const dispatch = useAppDispatch()

useEffect(() => {
const token = localStorage.getItem('jwtToken')
const userId = localStorage.getItem('userId')

if (token && userId) {
dispatch(setUserDetails(userId))
}
}, [dispatch])
}

setUserDetails是一个Redux的action,用来设置用户的ID。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const setUserDetails = (userId: string) => {
return async (dispatch: Dispatch) => {
try {
const token = localStorage.getItem('jwtToken')
const response = await UserService.getUserByUserId(token, userId)
if (response.status === 200) {
dispatch(setCurrentUser(response.data))
} else {
console.log(response.data.message)
}
} catch (error) {
console.error(error)
}
}
}

此处使用了localStorage中的userId来向服务端请求了用户的信息,并将用户信息存储到Redux的状态中。

令牌过期

服务端向客户端返回的jwtToken是有过期时间的。当jwtToken过期后,客户端再向服务端发送请求时,服务端会返回401 Unauthorized错误。

在这种情况下,我们应当让用户重新登录。我在axios的拦截器中添加了一个拦截器,用来处理401错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
api.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response) {
if (error.response.status === 401 && error.response.statusText === 'Unauthorized') {
localStorage.removeItem('jwtToken')
window.location.reload()
}
}

return Promise.reject(error)
},
)

window.location.reload()会重新加载页面,没有jwtToken的情况下,用户会因为路由守卫而被重定向到登录页面。

其他的小改动

User接口

User接口的id字段改为_id

1
2
3
4
5
6
7
export interface User {
_id?: string
emailAddress?: string
username: string
password?: string
access_token?: string
}

这是因为在MongoDB中,每个文档都有一个_id字段,用来唯一标识文档。为了可以直接将MongoDB中的文档映射到User接口,我将id改为了_id

自定义滚动条

我使用的是Edge浏览器,它的滚动条不能说难看,只能说和好看不搭边。所以我在index.css中自定义了滚动条的样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
::-webkit-scrollbar {
width: 16px;
height: 16px;
}

::-webkit-scrollbar-track {
background: hsl( 220 calc( 1 * 6.5%) 18% / 1);
margin-bottom: 8px;
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 8px;
}

::-webkit-scrollbar-thumb {
background: hsl( 225 calc( 1 * 7.1%) 11% / 1);
background-clip: padding-box;
border: 4px solid transparent;
border-radius: 8px;
min-height: 40px;
}

::-webkit-scrollbar-corner {
background: transparent;
}

这些样式是根据Discord的滚动条样式来写的。