Compare commits
460 Commits
546f04dc0e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20986af1ba | ||
|
|
34d46a85f1 | ||
|
|
f8f66f0e33 | ||
|
|
58f0427350 | ||
|
|
e3db26323b | ||
|
|
4788434445 | ||
|
|
07bc4a6654 | ||
|
|
bd49edb586 | ||
|
|
f4a9cc9cda | ||
|
|
8817663371 | ||
|
|
19b8b92f49 | ||
|
|
f584b49cd4 | ||
|
|
13eefdd50c | ||
|
|
3cd9031eef | ||
|
|
94c901a233 | ||
|
|
1819c31267 | ||
|
|
00371b1dda | ||
|
|
2d48d2f536 | ||
|
|
4214ed9e10 | ||
|
|
198493cac1 | ||
|
|
f57347b834 | ||
|
|
f9dff68339 | ||
|
|
48bd884690 | ||
|
|
b85b6833b6 | ||
|
|
29ea0c5b84 | ||
|
|
508218a1c5 | ||
|
|
98774036cd | ||
|
|
e15e1aa4c8 | ||
|
|
1c6c0eaf84 | ||
|
|
02b0708426 | ||
|
|
d433ceb4a9 | ||
|
|
d76abcf512 | ||
|
|
6ca9946499 | ||
|
|
a549773eb2 | ||
|
|
faf594b2f6 | ||
|
|
185f5480fa | ||
|
|
b4a60bcbe2 | ||
|
|
d57b023769 | ||
|
|
4b5f0bcdd6 | ||
|
|
3f9ce06ed6 | ||
|
|
3def4d7449 | ||
|
|
4b9d78d0d5 | ||
|
|
1f6f8a768f | ||
|
|
a7c61d9306 | ||
|
|
0247eaeda9 | ||
|
|
f9dfa466f0 | ||
|
|
c2f99f5c62 | ||
|
|
6f6dd3bfac | ||
|
|
e7f0af8e6e | ||
|
|
3bd0d79fdc | ||
|
|
1e213ddbc4 | ||
|
|
839fb4c4b7 | ||
|
|
35afcf03bb | ||
|
|
5864108f99 | ||
|
|
d486c9df79 | ||
|
|
31e627ce20 | ||
|
|
ca565e3c3e | ||
|
|
12861b80a1 | ||
|
|
02b1d28a6b | ||
|
|
f3850a6e2f | ||
|
|
a9b4a71c0b | ||
|
|
8df803b3d8 | ||
|
|
2db2bc4c66 | ||
|
|
ae837b71aa | ||
|
|
fd3684c436 | ||
|
|
7fcf4ce50b | ||
|
|
4199335ef8 | ||
|
|
37281232c0 | ||
|
|
8b3022bed0 | ||
|
|
8acf72c7bf | ||
|
|
f097a491ae | ||
|
|
e90e1911e8 | ||
|
|
d6f1cae7b7 | ||
|
|
0754b4128f | ||
|
|
1cb8ac3fff | ||
|
|
f13623f4fc | ||
|
|
2cf9a20910 | ||
|
|
59191cc42e | ||
|
|
98132eb67c | ||
|
|
204748699e | ||
|
|
7d90d4b0f0 | ||
|
|
744f02677d | ||
|
|
8fd9f21c78 | ||
|
|
5dfcf7a621 | ||
|
|
65602f09f2 | ||
|
|
02d6ee4102 | ||
|
|
c9fffbeb12 | ||
|
|
ce692bb763 | ||
|
|
1e7e175389 | ||
|
|
c9d9dd8144 | ||
|
|
03f8facde0 | ||
|
|
da4325475c | ||
|
|
4cb7522251 | ||
|
|
578b3507fd | ||
|
|
b976fed8e7 | ||
|
|
48382c4592 | ||
|
|
095b454539 | ||
|
|
cbdccfb5a7 | ||
|
|
32719b45ea | ||
|
|
b32f60d94d | ||
|
|
d524304b29 | ||
|
|
7689ec590a | ||
|
|
6517b04215 | ||
|
|
51fbdc0f71 | ||
|
|
4bf55749bb | ||
|
|
9e8c9bc508 | ||
|
|
e1039703d1 | ||
|
|
ace3f8c4f9 | ||
|
|
30c09d0613 | ||
|
|
dec9068cc8 | ||
|
|
19cfd84e7d | ||
|
|
d00dfab898 | ||
|
|
be27894f95 | ||
|
|
9b0d91a615 | ||
|
|
ad2fd93e02 | ||
|
|
5b425260c9 | ||
|
|
31133f5704 | ||
|
|
93dad0b896 | ||
|
|
8969fb7cb6 | ||
|
|
82c7c3772e | ||
|
|
df217b167e | ||
|
|
2f85aef136 | ||
|
|
b4d63a709b | ||
|
|
f64349d802 | ||
|
|
86ace28066 | ||
|
|
b46449a6e4 | ||
|
|
19b2fce904 | ||
|
|
a7df2c689a | ||
|
|
6ce8acdb2e | ||
|
|
149f003175 | ||
|
|
f0ca0fbbd4 | ||
|
|
3e5fc722e6 | ||
|
|
a646d7908a | ||
|
|
cfe8df43d1 | ||
|
|
743ccd1172 | ||
|
|
3c5bd187b7 | ||
|
|
27035eb2ca | ||
|
|
7a308e2261 | ||
|
|
3cc60986ab | ||
|
|
13edd23245 | ||
|
|
bc386908f7 | ||
|
|
c1074d8a2c | ||
|
|
6ee209f9f6 | ||
|
|
230cc08182 | ||
|
|
d60a11995e | ||
|
|
68886573a8 | ||
|
|
fabd325976 | ||
|
|
3a56415968 | ||
|
|
ed5e962370 | ||
|
|
dd39c3e63c | ||
|
|
02485de52c | ||
|
|
8891cd23af | ||
|
|
661cebdb24 | ||
|
|
8b3b32422f | ||
|
|
dffa773acc | ||
|
|
51c6d1f0a6 | ||
|
|
bd35f5c3eb | ||
|
|
7409427ce5 | ||
|
|
7bc843d440 | ||
|
|
6c1dd703bc | ||
|
|
046831b4e5 | ||
|
|
937af27698 | ||
|
|
5469ff6826 | ||
|
|
f8e6fcac46 | ||
|
|
ab96ef889d | ||
|
|
b1e618e07c | ||
|
|
62ee2ef01f | ||
|
|
04125a1495 | ||
|
|
110a90ed7a | ||
|
|
bfc14777be | ||
|
|
4e34e70a11 | ||
|
|
2d2bc7be83 | ||
|
|
5d6c4d6660 | ||
|
|
ab8895b008 | ||
|
|
d5e349ee88 | ||
|
|
760e5a118a | ||
|
|
2d78e39ca1 | ||
|
|
afd9193dea | ||
|
|
bc7b932c5c | ||
|
|
4807038619 | ||
|
|
e18024b851 | ||
|
|
1dfe702c58 | ||
|
|
04a63ced87 | ||
|
|
50e3e21634 | ||
|
|
5e5436b02c | ||
|
|
72016c5da1 | ||
|
|
bef6e88bf7 | ||
|
|
3789e476f7 | ||
|
|
ba71d66db8 | ||
|
|
af55143292 | ||
|
|
b824186c37 | ||
|
|
5034eb1da5 | ||
|
|
5e44a273fc | ||
|
|
484381c6e5 | ||
|
|
349e0933c3 | ||
|
|
08556c9d40 | ||
|
|
687bc7a9aa | ||
|
|
5a34054024 | ||
|
|
306bfa2b82 | ||
|
|
506790aefa | ||
|
|
ab1ef2c30b | ||
|
|
61bc1a265c | ||
|
|
9c45f3e13e | ||
|
|
23ad29fb2d | ||
|
|
5b64c6adcf | ||
|
|
dd42f5e54e | ||
|
|
2d7b7818d7 | ||
|
|
c27eb37852 | ||
|
|
bc48cf801b | ||
|
|
e46661ba15 | ||
|
|
9cb71af85b | ||
|
|
241ff714b8 | ||
|
|
db43de19c4 | ||
|
|
38c28c3fb6 | ||
|
|
0df1149618 | ||
|
|
aeafcb5b97 | ||
|
|
324962b0fc | ||
|
|
f5f3774daf | ||
|
|
e666dc573a | ||
|
|
11362a5689 | ||
|
|
7c7e641d1f | ||
|
|
fabdd192dd | ||
|
|
8d7ddd46be | ||
|
|
4b91bc9dbb | ||
|
|
80c6f0b7a7 | ||
|
|
4eff829a30 | ||
|
|
96ca578c70 | ||
|
|
7a0110180d | ||
|
|
b36fe7a67e | ||
|
|
6e73662860 | ||
|
|
318f75a7cc | ||
|
|
bc5ed9e602 | ||
|
|
8c8d17a1c7 | ||
|
|
71dee043a3 | ||
|
|
059078ea8f | ||
|
|
674fe000f4 | ||
|
|
85477fe46e | ||
|
|
dced175d7a | ||
|
|
bd857b840b | ||
|
|
5d1c395340 | ||
|
|
0e17b37156 | ||
|
|
fb48c44655 | ||
|
|
7378024235 | ||
|
|
1c985f28a2 | ||
|
|
449c0a8806 | ||
|
|
e1e42ea188 | ||
|
|
823eef76b0 | ||
|
|
3b0b5ff032 | ||
|
|
6112b4b207 | ||
|
|
9e8e967eb9 | ||
|
|
697082193f | ||
|
|
86d68fd5e5 | ||
|
|
ffa8ac73de | ||
|
|
f01f3b02f4 | ||
|
|
ad4e873d2f | ||
|
|
a77e22a3ea | ||
|
|
1fa91279e2 | ||
|
|
debdb93935 | ||
|
|
81cdb4afd9 | ||
|
|
bc08cd3c8c | ||
|
|
c24078b29d | ||
|
|
f04748aa5c | ||
|
|
5ce97283f1 | ||
|
|
d6f794a094 | ||
|
|
47bbf12176 | ||
|
|
2cee988ada | ||
|
|
04989762d9 | ||
|
|
89db6591a0 | ||
|
|
d173fb7842 | ||
|
|
4133c13cf8 | ||
|
|
a1eddf813d | ||
|
|
39b4a6d8a6 | ||
|
|
e4cf9d6a68 | ||
|
|
f29538762b | ||
|
|
7616a49ff8 | ||
|
|
42aefdd2f1 | ||
|
|
5fadb76a20 | ||
|
|
5474eac554 | ||
|
|
a12a8830d4 | ||
|
|
6c5f3aac85 | ||
|
|
6e164cbdfb | ||
|
|
af694f6f6c | ||
|
|
c5ce13b13c | ||
|
|
0026cae639 | ||
|
|
376177d78e | ||
|
|
6c9ee005fd | ||
|
|
82c5aeaaa0 | ||
|
|
14c279cc80 | ||
|
|
67c6f11892 | ||
|
|
edf35b7dd0 | ||
|
|
de886dcfcc | ||
|
|
67f019713a | ||
|
|
2af396a2b8 | ||
|
|
20f12c97c1 | ||
|
|
15c4bcd48e | ||
|
|
e429bbbcdb | ||
|
|
020fd63c97 | ||
|
|
2771503b6f | ||
|
|
65458cf491 | ||
|
|
c23fdbf310 | ||
|
|
dfeed305e1 | ||
|
|
bc5485d622 | ||
|
|
d3b2949ff7 | ||
|
|
f376de2b48 | ||
|
|
459fca064c | ||
|
|
f436f84696 | ||
|
|
73e795e29f | ||
|
|
a709ac7ee0 | ||
|
|
beab35a25e | ||
|
|
aa0d0e86a5 | ||
|
|
f8a043e59f | ||
|
|
441fb5b5be | ||
|
|
29ea6f9142 | ||
|
|
cd22f62d60 | ||
|
|
b3ffdf8469 | ||
|
|
6c17a0e4eb | ||
|
|
8163100559 | ||
|
|
1fec2bba06 | ||
|
|
706a340407 | ||
|
|
7e81484932 | ||
|
|
d7d8351dc9 | ||
|
|
19657fd150 | ||
|
|
a6cef76ecf | ||
|
|
ee8e0e531e | ||
|
|
151dc31f2c | ||
|
|
0b1a4a53a5 | ||
|
|
02efac9a8e | ||
|
|
8d739dd863 | ||
|
|
c0c6c6ed1c | ||
|
|
d26c67f06d | ||
|
|
35d60642c0 | ||
|
|
a928577f2a | ||
|
|
4b93e5fd67 | ||
|
|
d6454f51c8 | ||
|
|
efc0f49b66 | ||
|
|
a860da96a0 | ||
|
|
692eb3d2a3 | ||
|
|
b6be09ef7c | ||
|
|
8e15c8126f | ||
|
|
80a42d5d86 | ||
|
|
b8f3886a1b | ||
|
|
5a80041ec3 | ||
|
|
d76e7e2bf5 | ||
|
|
4fa3e16ab7 | ||
|
|
9cc3a2149e | ||
|
|
fdf52c0548 | ||
|
|
a6ee231ad5 | ||
|
|
4bcc6e4347 | ||
|
|
9395104c20 | ||
|
|
f063c4d165 | ||
|
|
b3b077fa9d | ||
|
|
88123e1edb | ||
|
|
0106311a2a | ||
|
|
f5f2d5743f | ||
|
|
4e38ad8e20 | ||
|
|
41362a591c | ||
|
|
1b36a45252 | ||
|
|
38db2e1310 | ||
|
|
9a3e87d89c | ||
|
|
954b5d3430 | ||
|
|
6dfe59c5a8 | ||
|
|
b741cbf9ba | ||
|
|
d5fbc490ea | ||
|
|
276ce5cae8 | ||
|
|
3a9312654e | ||
|
|
0a10009613 | ||
|
|
8759b660f5 | ||
|
|
ae3b9c8226 | ||
|
|
faec599822 | ||
|
|
da1c7cd8cf | ||
|
|
a0bf323ac9 | ||
|
|
4a2014e10d | ||
|
|
a01a64116f | ||
|
|
f6f2590532 | ||
|
|
20f5484e90 | ||
|
|
14f5bbfec9 | ||
|
|
5d5b04ba05 | ||
|
|
0ef2859291 | ||
|
|
b82d32cad7 | ||
|
|
10da3b8e77 | ||
|
|
184a80436d | ||
|
|
2de4d3548d | ||
|
|
fc197ea41a | ||
|
|
43385780f8 | ||
|
|
791102c034 | ||
|
|
8bcb3e74b6 | ||
|
|
e4c26a07cf | ||
|
|
f118c6b6f5 | ||
|
|
cb947429fb | ||
|
|
b3d620a329 | ||
|
|
28ffd134df | ||
|
|
f600245d3b | ||
|
|
706d811087 | ||
|
|
e5dd3ade51 | ||
|
|
83719f5f44 | ||
|
|
082817d6cd | ||
|
|
ee79e3eefa | ||
|
|
6a1084eeca | ||
|
|
71e6d24d6e | ||
|
|
7686a9b7d1 | ||
|
|
4837c17c2e | ||
|
|
3d367711cc | ||
|
|
6f006f38a4 | ||
|
|
cb4aeaed21 | ||
|
|
791baf474c | ||
|
|
468de4f439 | ||
|
|
2ec4f634ae | ||
|
|
8f7e61dfd2 | ||
|
|
212c2fa5dc | ||
|
|
dd88e8d1b8 | ||
|
|
eaf0f98058 | ||
|
|
1acc73c7b4 | ||
|
|
23df74ddac | ||
|
|
70478584b7 | ||
|
|
90295f0d38 | ||
|
|
5ff726d834 | ||
|
|
ab1bc844ab | ||
|
|
167b157134 | ||
|
|
3b98fc4de3 | ||
|
|
4a32fd216b | ||
|
|
af9b0d7cf2 | ||
|
|
c82d718fa7 | ||
|
|
fc3df592bc | ||
|
|
5ce42bf651 | ||
|
|
6a8acd4717 | ||
|
|
03f6f2743f | ||
|
|
13c42ddf38 | ||
|
|
c13913f08a | ||
|
|
b7ce12ff5e | ||
|
|
dd7c578534 | ||
|
|
d473ff81bd | ||
|
|
c6bfca0482 | ||
|
|
b1e7f3e485 | ||
|
|
a85ea56bb7 | ||
|
|
ee670f86b6 | ||
|
|
85b48475de | ||
|
|
0af3e7a449 | ||
|
|
2b54a7a13a | ||
|
|
4cc4866db1 | ||
|
|
a3d5e93240 | ||
|
|
ed494413fd | ||
|
|
557234841d | ||
|
|
ea17ab2ddd | ||
|
|
20ef8a8514 | ||
|
|
124879f11f | ||
|
|
125938b8be | ||
|
|
2208a2d292 | ||
|
|
1deec533ad | ||
|
|
633cfed87b | ||
|
|
c51a6508e4 | ||
|
|
12c2e13505 | ||
|
|
372e71bc1c | ||
|
|
5fee5dd363 | ||
|
|
2ee73416e0 | ||
|
|
73a1536df7 | ||
|
|
8ebad65140 | ||
|
|
6896a1f8af | ||
|
|
b30035d5a8 | ||
|
|
6b0e781fdf | ||
|
|
fd6ceb82df |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,6 +2,6 @@
|
||||
thewhitesilk_config.json
|
||||
# **默认**数据目录
|
||||
thewhitesilk_data/
|
||||
|
||||
deno.lock
|
||||
node_modules/
|
||||
# Node.js
|
||||
package-lock.json
|
||||
node_modules/
|
||||
|
||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -5,10 +5,10 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "deno task debug",
|
||||
"name": "Run debug",
|
||||
"command": "npm run debug",
|
||||
"name": "Debug",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
},
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# 使用官方 Deno 镜像
|
||||
FROM denoland/deno:latest
|
||||
|
||||
# 设置镜像名称
|
||||
LABEL image.name="lingchair"
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制源代码
|
||||
COPY --exclude=.git --exclude=.gitignore --exclude=Dockerfile --exclude=readme.md --exclude=thewhitesilk_config.json --exclude=thewhitesilk_data . .
|
||||
|
||||
# 缓存依赖并构建项目
|
||||
RUN npm run install-dependencies
|
||||
|
||||
RUN npm run build-client
|
||||
|
||||
# 暴露应用端口(根据你的应用调整端口号)
|
||||
EXPOSE 3601
|
||||
|
||||
# 启动服务
|
||||
CMD ["npm", "run", "server"]
|
||||
3
client-protocol/ApiCallbackMessage.ts
Normal file
3
client-protocol/ApiCallbackMessage.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ApiCallbackMessage } from 'lingchair-internal-shared'
|
||||
|
||||
export type { ApiCallbackMessage as default }
|
||||
11
client-protocol/ApiDeclare.ts
Normal file
11
client-protocol/ApiDeclare.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from 'lingchair-internal-shared'
|
||||
|
||||
import { ClientEvent } from "lingchair-internal-shared"
|
||||
|
||||
import Message from "./Message.ts"
|
||||
|
||||
export type ClientEventData<T extends ClientEvent> =
|
||||
T extends "Client.onMessage" ? { message: Message } :
|
||||
never
|
||||
|
||||
export type ClientEventCallback<T extends ClientEvent> = (data: ClientEventData<T>) => void
|
||||
8
client-protocol/BaseClientObject.ts
Normal file
8
client-protocol/BaseClientObject.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
|
||||
export default class BaseClientObject {
|
||||
declare client: LingChairClient
|
||||
constructor(client: LingChairClient) {
|
||||
this.client = client
|
||||
}
|
||||
}
|
||||
11
client-protocol/CallbackError.ts
Normal file
11
client-protocol/CallbackError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
||||
|
||||
export default class CallbackError extends Error {
|
||||
declare code: number
|
||||
declare data?: object
|
||||
constructor(re: ApiCallbackMessage) {
|
||||
super(`[${re.code}] ${re.msg}${re.data ? ` (data: ${JSON.stringify(re.data)})` : ''}`)
|
||||
this.code = re.code
|
||||
this.data = re.data
|
||||
}
|
||||
}
|
||||
234
client-protocol/Chat.ts
Normal file
234
client-protocol/Chat.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import BaseClientObject from "./BaseClientObject.ts"
|
||||
import BaseChatSettingsBean from "./bean/BaseChatSettingsBean.ts"
|
||||
import ChatBean from "./bean/ChatBean.ts"
|
||||
import JoinRequestBean from "./bean/JoinRequestBean.ts"
|
||||
import MessageBean from "./bean/MessageBean.ts"
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
import JoinRequest from "./JoinRequest.ts"
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
import Message from "./Message.ts"
|
||||
|
||||
export default class Chat extends BaseClientObject {
|
||||
declare bean: ChatBean
|
||||
constructor(client: LingChairClient, bean: ChatBean) {
|
||||
super(client)
|
||||
this.bean = bean
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 实例化方法
|
||||
* ================================================
|
||||
*/
|
||||
static getForInvokeOnlyById(client: LingChairClient, id: string) {
|
||||
return new Chat(client, {
|
||||
id
|
||||
} as ChatBean)
|
||||
}
|
||||
static async getById(client: LingChairClient, id: string) {
|
||||
try {
|
||||
return await this.getByIdOrThrow(client, id)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
static async getByIdOrThrow(client: LingChairClient, id: string) {
|
||||
const re = await client.invoke("Chat.getInfo", {
|
||||
token: client.access_token,
|
||||
target: id,
|
||||
})
|
||||
if (re.code == 200)
|
||||
return new Chat(client, re.data as unknown as ChatBean)
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
/**
|
||||
* ================================================
|
||||
* 创建对话 (另类实例化方法)
|
||||
* ================================================
|
||||
*/
|
||||
static async getOrCreatePrivateChat(client: LingChairClient, user_id: string) {
|
||||
try {
|
||||
return await this.getOrCreatePrivateChatOrThrow(client, user_id)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
static async getOrCreatePrivateChatOrThrow(client: LingChairClient, user_id: string) {
|
||||
const re = await client.invoke("Chat.getIdForPrivate", {
|
||||
token: client.access_token,
|
||||
target: user_id,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
return new Chat(client, re.data as unknown as ChatBean)
|
||||
}
|
||||
static async createGroup(client: LingChairClient, title: string, name?: string) {
|
||||
try {
|
||||
return await this.createGroupOrThrow(client, title, name)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
static async createGroupOrThrow(client: LingChairClient, title: string, name?: string) {
|
||||
const re = await client.invoke("Chat.createGroup", {
|
||||
token: client.access_token,
|
||||
title,
|
||||
name,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
return new Chat(client, re.data as unknown as ChatBean)
|
||||
}
|
||||
/**
|
||||
* ================================================
|
||||
* 对话消息
|
||||
* ================================================
|
||||
*/
|
||||
async getMessages(page: number = 0) {
|
||||
return (await this.getMessageBeans(page)).map((v) => new Message(this.client, v))
|
||||
}
|
||||
async getMessagesOrThrow(page: number = 0) {
|
||||
return (await this.getMessageBeansOrThrow(page)).map((v) => new Message(this.client, v))
|
||||
}
|
||||
async getMessageBeans(page: number = 0) {
|
||||
try {
|
||||
return await this.getMessageBeansOrThrow(page)
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMessageBeansOrThrow(page: number = 0) {
|
||||
const re = await this.client.invoke("Chat.getMessageHistory", {
|
||||
token: this.client.access_token,
|
||||
page,
|
||||
target: this.bean.id,
|
||||
})
|
||||
if (re.code == 200) return re.data!.messages as MessageBean[]
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
async sendMessage(text: string) {
|
||||
try {
|
||||
return await this.sendMessageOrThrow(text)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
async sendMessageOrThrow(text: string) {
|
||||
const re = await this.client.invoke("Chat.sendMessage", {
|
||||
token: this.client.access_token,
|
||||
text,
|
||||
target: this.bean.id,
|
||||
})
|
||||
if (re.code == 200)
|
||||
return new Message(this.client, re.data!.message as MessageBean)
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
/**
|
||||
* ================================================
|
||||
* 加入对话申请
|
||||
* ================================================
|
||||
*/
|
||||
async getJoinRequests() {
|
||||
try {
|
||||
return await this.getJoinRequestsOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getJoinRequestsOrThrow() {
|
||||
const join_requests = await this.getJoinRequestBeansOrThrow()
|
||||
return join_requests.map((jr) => new JoinRequest(this.client, jr, this.bean.id))
|
||||
}
|
||||
async getJoinRequestBeans() {
|
||||
try {
|
||||
return await this.getJoinRequestBeansOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getJoinRequestBeansOrThrow() {
|
||||
const re = await this.client.invoke("Chat.getJoinRequests", {
|
||||
token: this.client.access_token
|
||||
})
|
||||
if (re.code == 200)
|
||||
return re.data!.join_requests as JoinRequestBean[]
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
/**
|
||||
* ================================================
|
||||
* 对话信息
|
||||
* ================================================
|
||||
*/
|
||||
async setAvatarFileHash(file_hash: string) {
|
||||
try {
|
||||
await this.setAvatarFileHashOrThrow(file_hash)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async setAvatarFileHashOrThrow(file_hash: string) {
|
||||
const re = await this.client.invoke("Chat.setAvatar", {
|
||||
token: this.client.access_token,
|
||||
file_hash,
|
||||
target: this.bean.id,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
this.bean.avatar_file_hash = file_hash
|
||||
}
|
||||
async updateSettings(args: BaseChatSettingsBean) {
|
||||
try {
|
||||
await this.updateSettingsOrThrow(args)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async updateSettingsOrThrow(args: BaseChatSettingsBean) {
|
||||
const re = await this.client.invoke("Chat.updateSettings", {
|
||||
token: this.client.access_token,
|
||||
target: this.bean.id,
|
||||
settings: args
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
this.bean.settings = args
|
||||
}
|
||||
async getTheOtherUserId() {
|
||||
try {
|
||||
return await this.getTheOtherUserIdOrThrow()
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
async getTheOtherUserIdOrThrow() {
|
||||
const re = await this.client.invoke("Chat.updateSettings", {
|
||||
token: this.client.access_token,
|
||||
target: this.bean.id,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
return re.data!.user_id as string
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 基本 Bean
|
||||
* ================================================
|
||||
*/
|
||||
getId() {
|
||||
return this.bean.id
|
||||
}
|
||||
getTitle() {
|
||||
return this.bean.title
|
||||
}
|
||||
getType() {
|
||||
return this.bean.type
|
||||
}
|
||||
isMember() {
|
||||
return this.bean.is_member
|
||||
}
|
||||
isAdmin() {
|
||||
return this.bean.is_admin
|
||||
}
|
||||
getAvatarFileHash() {
|
||||
return this.bean.avatar_file_hash
|
||||
}
|
||||
getSettings() {
|
||||
return this.bean.settings
|
||||
}
|
||||
}
|
||||
66
client-protocol/JoinRequest.ts
Normal file
66
client-protocol/JoinRequest.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import BaseClientObject from "./BaseClientObject.ts"
|
||||
import JoinRequestBean from "./bean/JoinRequestBean.ts"
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
import JoinRequestAction from "./type/JoinRequestAction.ts"
|
||||
|
||||
export default class JoinRequest extends BaseClientObject {
|
||||
declare bean: JoinRequestBean
|
||||
declare chat_id: string
|
||||
constructor(client: LingChairClient, bean: JoinRequestBean, chat_id: string) {
|
||||
super(client)
|
||||
this.bean = bean
|
||||
this.chat_id = chat_id
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 操作
|
||||
* ================================================
|
||||
*/
|
||||
async accept() {
|
||||
return await this.process('accept')
|
||||
}
|
||||
async acceptOrThrow() {
|
||||
return await this.processOrThrow('accept')
|
||||
}
|
||||
async remove() {
|
||||
return await this.process('remove')
|
||||
}
|
||||
async removOrThrow() {
|
||||
return await this.processOrThrow('remove')
|
||||
}
|
||||
async process(action: JoinRequestAction) {
|
||||
try {
|
||||
await this.processOrThrow(action)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async processOrThrow(action: JoinRequestAction) {
|
||||
const re = await this.client.invoke("Chat.processJoinRequest", {
|
||||
token: this.client.access_token,
|
||||
chat_id: this.chat_id,
|
||||
user_id: this.bean.user_id,
|
||||
action,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 基本 Bean
|
||||
* ================================================
|
||||
*/
|
||||
getAvatarFileHash() {
|
||||
return this.bean.avatar_file_hash
|
||||
}
|
||||
getUserId() {
|
||||
return this.bean.user_id
|
||||
}
|
||||
getNickName() {
|
||||
return this.bean.title
|
||||
}
|
||||
getReason() {
|
||||
return this.bean.reason
|
||||
}
|
||||
}
|
||||
269
client-protocol/LingChairClient.ts
Normal file
269
client-protocol/LingChairClient.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// deno-lint-ignore-file no-explicit-any
|
||||
import { io, ManagerOptions, Socket, SocketOptions } from 'socket.io-client'
|
||||
import crypto from 'node:crypto'
|
||||
import { CallMethod, ClientEvent, ClientEventCallback } from './ApiDeclare.ts'
|
||||
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
||||
import { CallableMethodBeforeAuth, randomUUID } from "lingchair-internal-shared"
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
|
||||
import Message from "./Message.ts"
|
||||
|
||||
export default class LingChairClient {
|
||||
declare client: Socket
|
||||
declare access_token: string
|
||||
declare server_url: string
|
||||
declare device_id: string
|
||||
declare refresh_token?: string
|
||||
declare auto_fresh_token: boolean
|
||||
declare auth_cache: {
|
||||
refresh_token?: string,
|
||||
access_token?: string,
|
||||
account?: string,
|
||||
password?: string,
|
||||
}
|
||||
constructor(args: {
|
||||
server_url: string
|
||||
device_id: string,
|
||||
io?: Partial<ManagerOptions & SocketOptions>
|
||||
auto_fresh_token?: boolean
|
||||
}) {
|
||||
this.server_url = args.server_url
|
||||
this.auto_fresh_token = args.auto_fresh_token || false
|
||||
this.device_id = args.device_id
|
||||
this.client = io(args.server_url, {
|
||||
transports: ["polling", "websocket", "webtransport"],
|
||||
...args.io,
|
||||
auth: {
|
||||
...args.io?.auth,
|
||||
device_id: this.device_id,
|
||||
session_id: randomUUID(),
|
||||
},
|
||||
})
|
||||
this.client.on("The_White_Silk", (name: ClientEvent, data: any, _callback: (ret: unknown) => void) => {
|
||||
try {
|
||||
if (name == null || data == null) return
|
||||
for (const v of (this.events[name] || []))
|
||||
v(({
|
||||
"Client.onMessage": {
|
||||
message: new Message(this, data.msg)
|
||||
}
|
||||
})[name])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
events: { [K in ClientEvent]?: ClientEventCallback<K>[] } = {}
|
||||
on<K extends ClientEvent>(eventName: K, func: ClientEventCallback<K>) {
|
||||
if (this.events[eventName] == null)
|
||||
this.events[eventName] = []
|
||||
if (this.events[eventName].indexOf(func) == -1)
|
||||
this.events[eventName].push(func)
|
||||
}
|
||||
off<K extends ClientEvent>(eventName: K, func: ClientEventCallback<K>) {
|
||||
if (this.events[eventName] == null)
|
||||
this.events[eventName] = []
|
||||
const index = this.events[eventName].indexOf(func)
|
||||
if (index != -1)
|
||||
this.events[eventName].splice(index, 1)
|
||||
}
|
||||
connect() {
|
||||
this.client.connect()
|
||||
}
|
||||
disconnect() {
|
||||
this.client.disconnect()
|
||||
}
|
||||
reconnect() {
|
||||
this.disconnect()
|
||||
this.connect()
|
||||
}
|
||||
invoke(method: CallMethod, args: object = {}, timeout: number = 10000): Promise<ApiCallbackMessage> {
|
||||
return new Promise((resolve) => {
|
||||
this.client!.timeout(timeout).emit("The_White_Silk", method, args, (err: Error, res: ApiCallbackMessage) => {
|
||||
// 错误处理
|
||||
if (err) return resolve({
|
||||
code: -1,
|
||||
msg: err.message,
|
||||
})
|
||||
if (CallableMethodBeforeAuth.indexOf(method) == -1 && res.code == 401 && this.auto_fresh_token) {
|
||||
if (this.auth_cache)
|
||||
this.auth(this.auth_cache).then((re) => {
|
||||
if (!re) resolve(res)
|
||||
this.invoke(method, args, timeout).then((re) => resolve(re))
|
||||
})
|
||||
else
|
||||
resolve(res)
|
||||
} else
|
||||
resolve(res)
|
||||
})
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 建议在 auth 返回 true 时调用
|
||||
*/
|
||||
getCachedAccessToken() {
|
||||
return this.access_token
|
||||
}
|
||||
/**
|
||||
* 建议在 auth 返回 true 时调用
|
||||
*/
|
||||
getCachedRefreshToken() {
|
||||
return this.refresh_token
|
||||
}
|
||||
/**
|
||||
* 客户端上线
|
||||
*
|
||||
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
|
||||
*
|
||||
* 不会逐一尝试
|
||||
*/
|
||||
async auth(args: {
|
||||
refresh_token?: string,
|
||||
access_token?: string,
|
||||
account?: string,
|
||||
password?: string,
|
||||
}) {
|
||||
try {
|
||||
await this.authOrThrow(args)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 进行身份验证以接受客户端事件
|
||||
*
|
||||
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
|
||||
*
|
||||
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
|
||||
*
|
||||
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
|
||||
*
|
||||
* 多个验证方式不会逐一尝试
|
||||
*/
|
||||
async authOrThrow(args: {
|
||||
refresh_token?: string
|
||||
access_token?: string
|
||||
account?: string
|
||||
password?: string
|
||||
ignore_all_empty?: boolean
|
||||
}) {
|
||||
if ((!args.access_token && !args.refresh_token) && (!args.account && !args.password) && !args.ignore_all_empty)
|
||||
throw new Error('Access/Refresh token or account & password required, or ignore_all_empty=true')
|
||||
|
||||
this.auth_cache = args
|
||||
|
||||
let access_token = args.access_token
|
||||
if (!access_token && args.refresh_token) {
|
||||
const re = await this.invoke('User.refreshAccessToken', {
|
||||
refresh_token: args.refresh_token,
|
||||
})
|
||||
if (re.code == 200) {
|
||||
access_token = re.data!.access_token as string | undefined
|
||||
this.refresh_token = args.refresh_token
|
||||
} else
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
|
||||
if (!access_token && (args.account && args.password)) {
|
||||
const re = await this.invoke('User.login', {
|
||||
account: args.account,
|
||||
password: crypto.createHash('sha256').update(args.password).digest('hex'),
|
||||
})
|
||||
if (re.code == 200) {
|
||||
access_token = re.data!.access_token as string | undefined
|
||||
this.refresh_token = re.data!.refresh_token as string
|
||||
} else
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
|
||||
const re = await this.invoke('User.auth', {
|
||||
access_token: access_token
|
||||
})
|
||||
if (re.code == 200)
|
||||
this.access_token = access_token as string
|
||||
else
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
getBaseHttpUrl() {
|
||||
const url = new URL(this.server_url)
|
||||
return (({
|
||||
'ws:': 'http:',
|
||||
'wss:': 'https:',
|
||||
'http:': 'http:',
|
||||
'https:': 'https:',
|
||||
})[url.protocol] || 'http:') + '//' + url.host
|
||||
}
|
||||
getUrlForFileByHash(file_hash?: string, defaultUrl?: string) {
|
||||
return file_hash ? (this.getBaseHttpUrl() + '/uploaded_files/' + file_hash) : defaultUrl
|
||||
}
|
||||
async register(args: {
|
||||
nickname: string,
|
||||
username?: string,
|
||||
password: string,
|
||||
}) {
|
||||
try {
|
||||
return await this.registerOrThrow(args)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
async registerOrThrow({
|
||||
nickname,
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
nickname: string,
|
||||
username?: string,
|
||||
password: string,
|
||||
}) {
|
||||
const re = await this.invoke('User.register', {
|
||||
nickname,
|
||||
username,
|
||||
password: crypto.createHash('sha256').update(password).digest('hex'),
|
||||
})
|
||||
if (re.code != 200)
|
||||
throw new CallbackError(re)
|
||||
return re.data!.user_id as string
|
||||
}
|
||||
async uploadFile({
|
||||
chatId,
|
||||
fileData,
|
||||
fileName,
|
||||
}: { fileName: string, fileData: ArrayBuffer | Blob | Response, chatId?: string }) {
|
||||
const form = new FormData()
|
||||
form.append("file",
|
||||
fileData instanceof ArrayBuffer
|
||||
? new File([fileData], fileName, { type: 'application/octet-stream' })
|
||||
: (
|
||||
fileData instanceof Blob ? fileData :
|
||||
new File([await fileData.arrayBuffer()], fileName, { type: 'application/octet-stream' })
|
||||
)
|
||||
)
|
||||
form.append('file_name', fileName)
|
||||
chatId && form.append('chat_id', chatId)
|
||||
|
||||
const re = await fetch(this.getBaseHttpUrl() + '/upload_file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Token": this.access_token,
|
||||
"Device-Id": this.device_id,
|
||||
} as HeadersInit,
|
||||
body: form,
|
||||
credentials: 'omit',
|
||||
})
|
||||
const text = await (await re.blob()).text()
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
// deno-lint-ignore no-empty
|
||||
} catch (_) { }
|
||||
if (!re.ok) throw new CallbackError({
|
||||
...(json == null ? {
|
||||
msg: text
|
||||
} : json),
|
||||
code: re.status,
|
||||
} as ApiCallbackMessage)
|
||||
return json.data.file_hash as string
|
||||
}
|
||||
}
|
||||
240
client-protocol/Message.ts
Normal file
240
client-protocol/Message.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import BaseClientObject from "./BaseClientObject.ts"
|
||||
import MessageBean from "./bean/MessageBean.ts"
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
import Chat from "./Chat.ts"
|
||||
import User from "./User.ts"
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
||||
|
||||
import * as marked from 'marked'
|
||||
import { text } from "node:stream/consumers";
|
||||
|
||||
class ChatMention extends BaseClientObject {
|
||||
declare chat_id?: string
|
||||
declare user_id?: string
|
||||
declare text?: string
|
||||
constructor(client: LingChairClient, {
|
||||
user_id,
|
||||
chat_id,
|
||||
text,
|
||||
}: {
|
||||
user_id?: string,
|
||||
chat_id?: string,
|
||||
text: string,
|
||||
}) {
|
||||
super(client)
|
||||
this.user_id = user_id
|
||||
this.chat_id = chat_id
|
||||
this.text = text
|
||||
}
|
||||
async getChat() {
|
||||
return await Chat.getById(this.client, this.chat_id as string)
|
||||
}
|
||||
async getUser() {
|
||||
return await User.getById(this.client, this.user_id as string)
|
||||
}
|
||||
getText() {
|
||||
return this.text
|
||||
}
|
||||
}
|
||||
|
||||
type FileType = 'Video' | 'Image' | 'File'
|
||||
type MentionType = 'ChatMention' | 'UserMention'
|
||||
|
||||
class ChatAttachment extends BaseClientObject {
|
||||
declare file_hash: string
|
||||
declare file_name: string
|
||||
constructor(client: LingChairClient, {
|
||||
file_hash,
|
||||
file_name
|
||||
}: {
|
||||
file_hash: string,
|
||||
file_name: string
|
||||
}) {
|
||||
super(client)
|
||||
this.file_name = file_name
|
||||
this.file_hash = file_hash
|
||||
}
|
||||
async blob() {
|
||||
try {
|
||||
return await this.blobOrThrow()
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
fetch(init?: RequestInit) {
|
||||
const url = this.client.getUrlForFileByHash(this.file_hash)
|
||||
return fetch(url!, init)
|
||||
}
|
||||
async blobOrThrow() {
|
||||
const re = await this.fetch()
|
||||
const blob = await re.blob()
|
||||
if (!re.ok) throw new CallbackError({
|
||||
msg: await blob.text(),
|
||||
code: re.status,
|
||||
} as ApiCallbackMessage)
|
||||
return blob
|
||||
}
|
||||
async getMimeType() {
|
||||
try {
|
||||
return await this.getMimeTypeOrThrow()
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
async getMimeTypeOrThrow() {
|
||||
const re = await this.fetch({
|
||||
method: 'HEAD'
|
||||
})
|
||||
if (re.ok) {
|
||||
const t = re.headers.get('content-type')
|
||||
if (t)
|
||||
return t
|
||||
throw new Error("Unable to get Content-Type")
|
||||
}
|
||||
throw new CallbackError({
|
||||
msg: await re.text(),
|
||||
code: re.status,
|
||||
} as ApiCallbackMessage)
|
||||
}
|
||||
async getLength() {
|
||||
try {
|
||||
return await this.getLengthOrThrow()
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
async getLengthOrThrow() {
|
||||
const re = await this.fetch({
|
||||
method: 'HEAD'
|
||||
})
|
||||
if (re.ok) {
|
||||
const contentLength = re.headers.get('content-length')
|
||||
if (contentLength)
|
||||
return parseInt(contentLength)
|
||||
throw new Error("Unable to get Content-Length")
|
||||
}
|
||||
throw new CallbackError({
|
||||
msg: await re.text(),
|
||||
code: re.status,
|
||||
} as ApiCallbackMessage)
|
||||
}
|
||||
getFileHash() {
|
||||
return this.file_hash
|
||||
}
|
||||
getFileName() {
|
||||
return this.file_name
|
||||
}
|
||||
}
|
||||
|
||||
export default class Message extends BaseClientObject {
|
||||
declare bean: MessageBean
|
||||
constructor(client: LingChairClient, bean: MessageBean) {
|
||||
super(client)
|
||||
this.bean = bean
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 基本 Bean
|
||||
* ================================================
|
||||
*/
|
||||
getId() {
|
||||
return this.bean.id
|
||||
}
|
||||
getChatId() {
|
||||
return this.bean.chat_id
|
||||
}
|
||||
async getChat() {
|
||||
return await Chat.getById(this.client, this.bean.chat_id as string)
|
||||
}
|
||||
getText() {
|
||||
return this.bean.text
|
||||
}
|
||||
parseWithTransformers({
|
||||
attachment,
|
||||
mention,
|
||||
}: {
|
||||
attachment?: ({ text, fileType, attachment }: { text: string, fileType: FileType, attachment: ChatAttachment }) => string,
|
||||
mention?: ({ text, mentionType, mention }: { text: string, mentionType: MentionType, mention: ChatMention }) => string,
|
||||
}) {
|
||||
return new marked.Marked({
|
||||
async: false,
|
||||
extensions: [
|
||||
{
|
||||
name: 'text',
|
||||
renderer: ({ text }) => text,
|
||||
},
|
||||
{
|
||||
name: 'heading',
|
||||
renderer({ tokens }) {
|
||||
return this.parser.parseInline(tokens!)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paragraph',
|
||||
renderer({ tokens }) {
|
||||
return this.parser.parseInline(tokens!)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
renderer: ({ text, href }) => {
|
||||
const mentionType = /^(UserMention|ChatMention)=.*/.exec(text)?.[1] as MentionType
|
||||
const fileType = (/^(Video|File|Image)=.*/.exec(text)?.[1] || 'Image') as FileType
|
||||
|
||||
if (fileType != null && /tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
|
||||
const file_hash = /^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]!
|
||||
let file_name: string = /^(Video|File|Image)=(.*)/.exec(text)?.[2] || text
|
||||
file_name.trim() == '' && (file_name = 'Unnamed_File')
|
||||
return attachment ? attachment({ text: text, attachment: new ChatAttachment(this.client, { file_hash, file_name }), fileType: fileType, }) : text
|
||||
}
|
||||
if (mentionType != null && /^tws:\/\/chat\?id=[A-Za-z0-9]+/.test(href)) {
|
||||
const id = /^tws:\/\/chat\?id=(.*)/.exec(href)?.[1]!
|
||||
const label = /^(User|Chat)Mention=(.*)/.exec(text)?.[2] || ''
|
||||
return mention ? mention({
|
||||
text: text,
|
||||
mention: new ChatMention(this.client, {
|
||||
[({
|
||||
ChatMention: 'chat_id',
|
||||
UserMention: 'user_id',
|
||||
})[mentionType]]: id,
|
||||
text: label,
|
||||
}),
|
||||
mentionType: mentionType,
|
||||
}) : text
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}).parse(this.getText()) as string
|
||||
}
|
||||
getAttachments() {
|
||||
const attachments: ChatAttachment[] = []
|
||||
this.parseWithTransformers({
|
||||
attachment({ attachment }) {
|
||||
attachments.push(attachment)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
return attachments
|
||||
}
|
||||
getMentions() {
|
||||
const mentions: ChatMention[] = []
|
||||
this.parseWithTransformers({
|
||||
mention({ mention }) {
|
||||
mentions.push(mention)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
return mentions
|
||||
}
|
||||
getUserId() {
|
||||
return this.bean.user_id
|
||||
}
|
||||
async getUser() {
|
||||
return await User.getById(this.client, this.bean.user_id as string)
|
||||
}
|
||||
getTime() {
|
||||
return this.bean.time
|
||||
}
|
||||
}
|
||||
13
client-protocol/RecentChat.ts
Normal file
13
client-protocol/RecentChat.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||
import Chat from "./Chat.ts"
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
|
||||
export default class RecentChat extends Chat {
|
||||
declare bean: RecentChatBean
|
||||
constructor(client: LingChairClient, bean: RecentChatBean) {
|
||||
super(client, bean)
|
||||
}
|
||||
getContent() {
|
||||
return this.bean.content
|
||||
}
|
||||
}
|
||||
55
client-protocol/User.ts
Normal file
55
client-protocol/User.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import BaseClientObject from "./BaseClientObject.ts"
|
||||
import UserBean from "./bean/UserBean.ts"
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
|
||||
export default class User extends BaseClientObject {
|
||||
declare bean: UserBean
|
||||
constructor(client: LingChairClient, bean: UserBean) {
|
||||
super(client)
|
||||
this.bean = bean
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 实例化方法
|
||||
* ================================================
|
||||
*/
|
||||
static getForInvokeOnlyById(client: LingChairClient, id: string) {
|
||||
return new User(client, {
|
||||
id
|
||||
} as UserBean)
|
||||
}
|
||||
static async getById(client: LingChairClient, id: string) {
|
||||
try {
|
||||
return await this.getByIdOrThrow(client, id)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
static async getByIdOrThrow(client: LingChairClient, id: string) {
|
||||
const re = await client.invoke("User.getInfo", {
|
||||
token: client.access_token,
|
||||
target: id,
|
||||
})
|
||||
if (re.code == 200)
|
||||
return new User(client, re.data as unknown as UserBean)
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 基本 Bean
|
||||
* ================================================
|
||||
*/
|
||||
getId() {
|
||||
return this.bean.id
|
||||
}
|
||||
getUserName() {
|
||||
return this.bean.username
|
||||
}
|
||||
getNickName() {
|
||||
return this.bean.nickname
|
||||
}
|
||||
getAvatarFileHash() {
|
||||
return this.bean.avatar_file_hash
|
||||
}
|
||||
}
|
||||
232
client-protocol/UserMySelf.ts
Normal file
232
client-protocol/UserMySelf.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
import Chat from "./Chat.ts"
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
import RecentChat from "./RecentChat.ts"
|
||||
import User from "./User.ts"
|
||||
import ChatBean from "./bean/ChatBean.ts"
|
||||
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||
import UserBean from "./bean/UserBean.ts"
|
||||
|
||||
export default class UserMySelf extends User {
|
||||
/*
|
||||
* ================================================
|
||||
* 实例化方法
|
||||
* ================================================
|
||||
*/
|
||||
static async getMySelf(client: LingChairClient) {
|
||||
try {
|
||||
return await this.getMySelfOrThrow(client)
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
static async getMySelfOrThrow(client: LingChairClient) {
|
||||
const re = await client.invoke("User.getMyInfo", {
|
||||
token: client.access_token,
|
||||
})
|
||||
if (re.code == 200)
|
||||
return new UserMySelf(client, re.data as unknown as UserBean)
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 账号相关
|
||||
* ================================================
|
||||
*/
|
||||
async resetPassword(old_password: string, new_password: string) {
|
||||
try {
|
||||
await this.resetPasswordOrThrow(old_password, new_password)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async resetPasswordOrThrow(old_password: string, new_password: string) {
|
||||
const re = await this.client.invoke("User.resetPassword", {
|
||||
token: this.client.access_token,
|
||||
old_password,
|
||||
new_password,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 个人资料
|
||||
* ================================================
|
||||
*/
|
||||
async setAvatarFileHash(file_hash: string) {
|
||||
try {
|
||||
await this.setAvatarFileHashOrThrow(file_hash)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async setAvatarFileHashOrThrow(file_hash: string) {
|
||||
const re = await this.client.invoke("User.setAvatar", {
|
||||
token: this.client.access_token,
|
||||
file_hash,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
this.bean.avatar_file_hash = file_hash
|
||||
}
|
||||
async setUserName(user_name: string) {
|
||||
return await this.updateProfile({ username: user_name })
|
||||
}
|
||||
async setUserNameOrThrow(user_name: string) {
|
||||
await this.updateProfileOrThrow({ username: user_name })
|
||||
}
|
||||
async setNickName(nick_name: string) {
|
||||
return await this.updateProfile({ nickname: nick_name })
|
||||
}
|
||||
async setNickNameOrThrow(nick_name: string) {
|
||||
await this.updateProfileOrThrow({ nickname: nick_name })
|
||||
}
|
||||
async updateProfile(args: {
|
||||
username?: string,
|
||||
nickname?: string
|
||||
}) {
|
||||
try {
|
||||
await this.updateProfileOrThrow(args)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async updateProfileOrThrow({
|
||||
username,
|
||||
nickname
|
||||
}: {
|
||||
username?: string,
|
||||
nickname?: string
|
||||
}) {
|
||||
const re = await this.client.invoke("User.updateProfile", {
|
||||
token: this.client.access_token,
|
||||
nickname,
|
||||
username,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
nickname && (this.bean.nickname = nickname)
|
||||
username && (this.bean.username = username)
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 收藏对话
|
||||
* ================================================
|
||||
*/
|
||||
async addFavouriteChats(chat_ids: string[]) {
|
||||
try {
|
||||
await this.addFavouriteChatsOrThrow(chat_ids)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async addFavouriteChatsOrThrow(chat_ids: string[]) {
|
||||
const re = await this.client.invoke("User.addContacts", {
|
||||
token: this.client.access_token,
|
||||
targets: chat_ids,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
}
|
||||
async removeFavouriteChats(chat_ids: string[]) {
|
||||
try {
|
||||
await this.removeFavouriteChatsOrThrow(chat_ids)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
async removeFavouriteChatsOrThrow(chat_ids: string[]) {
|
||||
const re = await this.client.invoke("User.removeContacts", {
|
||||
token: this.client.access_token,
|
||||
targets: chat_ids,
|
||||
})
|
||||
if (re.code != 200) throw new CallbackError(re)
|
||||
}
|
||||
async getMyFavouriteChatBeans() {
|
||||
try {
|
||||
return await this.getMyFavouriteChatBeansOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMyFavouriteChatBeansOrThrow() {
|
||||
const re = await this.client.invoke("User.getMyContacts", {
|
||||
token: this.client.access_token
|
||||
})
|
||||
if (re.code == 200)
|
||||
return (re.data!.favourite_chats || re.data!.contacts_list) as ChatBean[]
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
async getMyFavouriteChats() {
|
||||
try {
|
||||
return await this.getMyFavouriteChatsOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMyFavouriteChatsOrThrow() {
|
||||
return (await this.getMyFavouriteChatBeansOrThrow()).map((bean) => new Chat(this.client, bean))
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 最近对话
|
||||
* ================================================
|
||||
*/
|
||||
async getMyRecentChatBeans() {
|
||||
try {
|
||||
return await this.getMyRecentChatBeansOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMyRecentChatBeansOrThrow() {
|
||||
const re = await this.client.invoke("User.getMyRecentChats", {
|
||||
token: this.client.access_token
|
||||
})
|
||||
if (re.code == 200)
|
||||
return re.data!.recent_chats as RecentChatBean[]
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
async getMyRecentChats() {
|
||||
try {
|
||||
return await this.getMyRecentChatsOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMyRecentChatsOrThrow() {
|
||||
return (await this.getMyRecentChatBeansOrThrow()).map((bean) => new RecentChat(this.client, bean))
|
||||
}
|
||||
/*
|
||||
* ================================================
|
||||
* 所有对话
|
||||
* ================================================
|
||||
*/
|
||||
async getMyAllChatBeans() {
|
||||
try {
|
||||
return await this.getMyAllChatBeansOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMyAllChatBeansOrThrow() {
|
||||
const re = await this.client.invoke("User.getMyAllChats", {
|
||||
token: this.client.access_token
|
||||
})
|
||||
if (re.code == 200)
|
||||
return re.data!.all_chats as ChatBean[]
|
||||
throw new CallbackError(re)
|
||||
}
|
||||
async getMyAllChats() {
|
||||
try {
|
||||
return await this.getMyAllChatsOrThrow()
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
async getMyAllChatsOrThrow() {
|
||||
return (await this.getMyAllChatBeansOrThrow()).map((bean) => new Chat(this.client, bean))
|
||||
}
|
||||
}
|
||||
5
client-protocol/bean/BaseChatSettingsBean.ts
Normal file
5
client-protocol/bean/BaseChatSettingsBean.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
interface BaseChatSettings {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default BaseChatSettings
|
||||
15
client-protocol/bean/ChatBean.ts
Normal file
15
client-protocol/bean/ChatBean.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import BaseChatSettingsBean from "./BaseChatSettingsBean.ts"
|
||||
import ChatType from "../type/ChatType.ts"
|
||||
|
||||
export default class ChatBean {
|
||||
declare type: ChatType
|
||||
declare id: string
|
||||
declare title: string
|
||||
declare avatar_file_hash?: string
|
||||
declare settings?: BaseChatSettingsBean
|
||||
|
||||
declare is_member: boolean
|
||||
declare is_admin: boolean
|
||||
|
||||
[key: string]: unknown
|
||||
}
|
||||
14
client-protocol/bean/GroupSettingsBean.ts
Normal file
14
client-protocol/bean/GroupSettingsBean.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import BaseChatSettings from "./BaseChatSettingsBean.ts"
|
||||
|
||||
interface GroupSettingsBean extends BaseChatSettings {
|
||||
allow_new_member_join?: boolean
|
||||
allow_new_member_from_invitation?: boolean
|
||||
new_member_join_method?: 'disabled' | 'allowed_by_admin' | 'answered_and_allowed_by_admin'
|
||||
answered_and_allowed_by_admin_question?: string
|
||||
|
||||
// 下面两个比较特殊, 由服务端给予
|
||||
group_title: string
|
||||
group_name: string
|
||||
}
|
||||
|
||||
export default GroupSettingsBean
|
||||
8
client-protocol/bean/JoinRequestBean.ts
Normal file
8
client-protocol/bean/JoinRequestBean.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default class JoinRequestBean {
|
||||
declare user_id: string
|
||||
declare nickname: string
|
||||
declare avatar_file_hash?: string
|
||||
declare reason?: string
|
||||
|
||||
[key: string]: unknown
|
||||
}
|
||||
7
client-protocol/bean/MessageBean.ts
Normal file
7
client-protocol/bean/MessageBean.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default class MessageBean {
|
||||
declare id: number
|
||||
declare text: string
|
||||
declare user_id?: string
|
||||
declare chat_id?: string
|
||||
declare time: string
|
||||
}
|
||||
5
client-protocol/bean/RecentChatBean.ts
Normal file
5
client-protocol/bean/RecentChatBean.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import ChatBean from "./ChatBean.ts"
|
||||
|
||||
export default class RecentChatBean extends ChatBean {
|
||||
declare content: string
|
||||
}
|
||||
6
client-protocol/bean/UserBean.ts
Normal file
6
client-protocol/bean/UserBean.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default class UserBean {
|
||||
declare id: string
|
||||
declare username?: string
|
||||
declare nickname: string
|
||||
declare avatar_file_hash?: string
|
||||
}
|
||||
28
client-protocol/main.ts
Normal file
28
client-protocol/main.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Chat from "./Chat.ts"
|
||||
import User from "./User.ts"
|
||||
import UserMySelf from "./UserMySelf.ts"
|
||||
import UserBean from "./bean/UserBean.ts"
|
||||
import ChatBean from "./bean/ChatBean.ts"
|
||||
import GroupSettingsBean from "./bean/GroupSettingsBean.ts"
|
||||
import JoinRequestBean from "./bean/JoinRequestBean.ts"
|
||||
import MessageBean from "./bean/MessageBean.ts"
|
||||
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||
|
||||
import LingChairClient from "./LingChairClient.ts"
|
||||
import CallbackError from "./CallbackError.ts"
|
||||
|
||||
export {
|
||||
LingChairClient,
|
||||
CallbackError,
|
||||
|
||||
Chat,
|
||||
User,
|
||||
UserMySelf,
|
||||
|
||||
UserBean,
|
||||
ChatBean,
|
||||
MessageBean,
|
||||
RecentChatBean,
|
||||
JoinRequestBean,
|
||||
}
|
||||
export type { GroupSettingsBean }
|
||||
11
client-protocol/package.json
Normal file
11
client-protocol/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "lingchair-client-protocol",
|
||||
"type": "module",
|
||||
"main": "./main.ts",
|
||||
"dependencies": {
|
||||
"lingchair-internal-shared": "*",
|
||||
"marked": "16.3.0",
|
||||
"socket.io-client": "4.8.1",
|
||||
"crypto-browserify": "3.12.1"
|
||||
}
|
||||
}
|
||||
3
client-protocol/type/ChatType.ts
Normal file
3
client-protocol/type/ChatType.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
type ChatType = 'private' | 'group'
|
||||
|
||||
export default ChatType
|
||||
3
client-protocol/type/JoinRequestAction.ts
Normal file
3
client-protocol/type/JoinRequestAction.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
type JoinRequestAction = 'accept' | 'remove'
|
||||
|
||||
export default JoinRequestAction
|
||||
8
client-protocol/type/OnMessageData.ts
Normal file
8
client-protocol/type/OnMessageData.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import MessageBean from '../bean/MessageBean.ts'
|
||||
|
||||
interface OnMessageData {
|
||||
chat: string
|
||||
msg: MessageBean
|
||||
}
|
||||
|
||||
export default OnMessageData
|
||||
23
client/ClientCache.ts
Normal file
23
client/ClientCache.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Chat, User } from "lingchair-client-protocol"
|
||||
import getClient from "./getClient"
|
||||
|
||||
type CouldCached = User | Chat | null
|
||||
export default class ClientCache {
|
||||
static caches: { [key: string]: CouldCached } = {}
|
||||
|
||||
static async getUser(id: string) {
|
||||
const k = 'user_' + id
|
||||
if (this.caches[k] != null)
|
||||
return this.caches[k] as User | null
|
||||
this.caches[k] = await User.getById(getClient(), id)
|
||||
return this.caches[k]
|
||||
}
|
||||
|
||||
static async getChat(id: string) {
|
||||
const k = 'chat_' + id
|
||||
if (this.caches[k] != null)
|
||||
return this.caches[k] as Chat | null
|
||||
this.caches[k] = await Chat.getById(getClient(), id)
|
||||
return this.caches[k]
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// @ts-types="npm:@types/crypto-js"
|
||||
import * as CryptoJS from 'crypto-js'
|
||||
|
||||
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
|
||||
|
||||
const aes = {
|
||||
enc: (data: string, key: string) => CryptoJS.AES.encrypt(data, key).toString(),
|
||||
dec: (data: string, key: string) => CryptoJS.AES.decrypt(data, key).toString(CryptoJS.enc.Utf8),
|
||||
}
|
||||
|
||||
const key = location.host + '_TWS_姐姐'
|
||||
|
||||
if (dataIsEmpty) localStorage.tws_data = aes.enc('{}', key)
|
||||
|
||||
let _dec = aes.dec(localStorage.tws_data, key)
|
||||
if (_dec == '') _dec = '{}'
|
||||
|
||||
const _data_cached = JSON.parse(_dec)
|
||||
|
||||
// 類型定義
|
||||
declare global {
|
||||
interface Window {
|
||||
data: {
|
||||
apply(): void
|
||||
access_token?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-window
|
||||
(window.data == null) && (window.data = new Proxy({
|
||||
apply() {}
|
||||
}, {
|
||||
get(_obj, k) {
|
||||
if (k == '_cached') return _data_cached
|
||||
if (k == 'apply') return () => localStorage.tws_data = aes.enc(JSON.stringify(_data_cached), key)
|
||||
return _data_cached[k]
|
||||
},
|
||||
set(_obj, k, v) {
|
||||
if (k == '_cached') return false
|
||||
_data_cached[k] = v
|
||||
return true
|
||||
}
|
||||
}))
|
||||
|
||||
// deno-lint-ignore no-window
|
||||
export default window.data
|
||||
@@ -1,7 +0,0 @@
|
||||
export type CallMethod =
|
||||
"User.auth" |
|
||||
"User.register" |
|
||||
"User.login"
|
||||
|
||||
export type ClientEvent =
|
||||
"Client.onMessage"
|
||||
@@ -1,43 +0,0 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { CallMethod, ClientEvent } from './ApiDeclare.ts'
|
||||
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
||||
|
||||
type UnknownObject = { [key: string]: unknown }
|
||||
|
||||
class Client {
|
||||
static socket?: Socket
|
||||
static events: { [key: string]: (data: UnknownObject) => UnknownObject } = {}
|
||||
static connect() {
|
||||
this.socket?.disconnect()
|
||||
this.socket && delete this.socket
|
||||
this.socket = io({
|
||||
transports: ['websocket']
|
||||
})
|
||||
this.socket!.on("The_White_Silk", (name: string, data: UnknownObject, callback: (ret: UnknownObject) => void) => {
|
||||
try {
|
||||
if (name == null || data == null) return
|
||||
const re = this.events[name]?.(data)
|
||||
re && callback(re)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
static invoke(method: CallMethod, args: UnknownObject = {}, timeout: number = 5000): Promise<ApiCallbackMessage> {
|
||||
if (this.socket == null) throw new Error("客戶端未與伺服器端建立連接!")
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket!.timeout(timeout).emit("The_White_Silk", method, args, (err: string, res: ApiCallbackMessage) => {
|
||||
if (err) return reject(err)
|
||||
resolve(res)
|
||||
})
|
||||
})
|
||||
}
|
||||
static on(eventName: ClientEvent, func: (data: UnknownObject) => UnknownObject) {
|
||||
this.events[eventName] = func
|
||||
}
|
||||
static off(eventName: ClientEvent){
|
||||
delete this.events[eventName]
|
||||
}
|
||||
}
|
||||
|
||||
export default Client
|
||||
@@ -1,5 +0,0 @@
|
||||
export default class Message {
|
||||
declare id: number
|
||||
declare text: string
|
||||
declare user_id: string
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default class RecentChat {
|
||||
declare id: string
|
||||
declare title: string
|
||||
declare avatar: string | null
|
||||
declare content: string
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default class User {
|
||||
declare id: string
|
||||
declare count: number
|
||||
declare username: string | null
|
||||
declare nickname: string
|
||||
declare avatar: string | null
|
||||
}
|
||||
71
client/data.ts
Normal file
71
client/data.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
|
||||
|
||||
class Aes {
|
||||
static randomIv() {
|
||||
return crypto.randomBytes(12)
|
||||
}
|
||||
static normalizeKey(key: string, keyLength = 32) {
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(key)
|
||||
const keyBuffer = hash.digest()
|
||||
return keyLength ? keyBuffer.subarray(0, keyLength) : keyBuffer
|
||||
}
|
||||
static encrypt(data: string, key: string) {
|
||||
const iv = this.randomIv()
|
||||
return Buffer.concat([iv, crypto.createCipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(data)]).toString('hex')
|
||||
}
|
||||
static decrypt(data: string, key: string) {
|
||||
const buffer = Buffer.from(data, 'hex')
|
||||
const iv = buffer.subarray(0, 12)
|
||||
return crypto.createDecipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(buffer.subarray(12)).toString()
|
||||
}
|
||||
}
|
||||
|
||||
// 尽可能防止被窃取, 虽然理论上还是会被窃取
|
||||
const key = crypto.createHash('sha256').update(location.host + '_TWS_姐姐_' + navigator.userAgent).digest().toString('base64')
|
||||
|
||||
if (dataIsEmpty) localStorage.tws_data = Aes.encrypt('{}', key)
|
||||
|
||||
let _data_cached
|
||||
try {
|
||||
_data_cached = JSON.parse(Aes.decrypt(localStorage.tws_data, key))
|
||||
} catch (e) {
|
||||
console.warn("数据解密失败, 使用空数据...", e)
|
||||
_data_cached = {}
|
||||
}
|
||||
|
||||
type IData = {
|
||||
refresh_token?: string
|
||||
split_sizes: number[]
|
||||
apply(): void
|
||||
access_token?: string
|
||||
device_id: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
data?: IData
|
||||
}
|
||||
}
|
||||
|
||||
const data = new Proxy({} as IData, {
|
||||
get(_obj, k) {
|
||||
if (k == '_cached') return _data_cached
|
||||
if (k == 'apply') return () => localStorage.tws_data = Aes.encrypt(JSON.stringify(_data_cached), key)
|
||||
return _data_cached[k]
|
||||
},
|
||||
set(_obj, k, v) {
|
||||
if (k == '_cached') return false
|
||||
_data_cached[k] = v
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
if (new URL(location.href).searchParams.get('export_data') == 'true') {
|
||||
window.data = data
|
||||
console.warn("警告: 将 data 暴露到 window 有可能会导致令牌泄露!")
|
||||
}
|
||||
|
||||
export default data
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"tasks": {
|
||||
"build": "deno run -A --node-modules-dir npm:vite build",
|
||||
"build-watch": "deno run -A --node-modules-dir npm:vite --watch build"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"jsxImportSourceTypes": "@types/react"
|
||||
},
|
||||
"imports": {
|
||||
"@deno/vite-plugin": "npm:@deno/vite-plugin@1.0.5",
|
||||
"@types/react": "npm:@types/react@18.3.1",
|
||||
"@types/react-dom": "npm:@types/react-dom@18.3.1",
|
||||
"@vitejs/plugin-react": "npm:@vitejs/plugin-react@4.7.0",
|
||||
"react": "npm:react@18.3.1",
|
||||
"react-dom": "npm:react-dom@18.3.1",
|
||||
"vite": "npm:vite@7.0.6",
|
||||
|
||||
"chalk": "npm:chalk@5.4.1",
|
||||
|
||||
"mdui": "npm:mdui@2.1.4",
|
||||
"split.js": "npm:split.js@1.3.2",
|
||||
"crypto-js": "npm:crypto-js@4.2.0",
|
||||
"socket.io-client": "npm:socket.io-client@4.8.1"
|
||||
}
|
||||
}
|
||||
8
client/env.d.ts
vendored
Normal file
8
client/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/// <reference types="mdui/jsx.zh-cn.d.ts" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __GIT_HASH__: string
|
||||
declare const __GIT_HASH_FULL__: string
|
||||
declare const __GIT_BRANCH__: string
|
||||
declare const __BUILD_TIME__: string
|
||||
19
client/getClient.ts
Normal file
19
client/getClient.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { LingChairClient } from 'lingchair-client-protocol'
|
||||
import data from "./data.ts"
|
||||
import { UAParser } from 'ua-parser-js'
|
||||
import { randomUUID } from 'lingchair-internal-shared'
|
||||
import performAuth from './performAuth.ts'
|
||||
|
||||
if (!data.device_id) {
|
||||
const ua = new UAParser(navigator.userAgent)
|
||||
data.device_id = `LingChair_Web_${ua.getOS() || 'unknown-os'}-${ua.getDevice().type || 'unknown_device'}-${randomUUID()}`
|
||||
}
|
||||
const client = new LingChairClient({
|
||||
server_url: '',
|
||||
device_id: data.device_id,
|
||||
auto_fresh_token: true,
|
||||
})
|
||||
|
||||
export default function getClient() {
|
||||
return client
|
||||
}
|
||||
BIN
client/icon.ico
Normal file
BIN
client/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -10,24 +10,15 @@
|
||||
<link rel="icon" href="icon.ico" />
|
||||
<link rel="stylesheet" href="./static/material_icons.css" />
|
||||
|
||||
<title>TheWhiteSilk</title>
|
||||
<title>LingChair</title>
|
||||
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<mdui-snackbar close-on-outside-click id="public_snackbar"></mdui-snackbar>
|
||||
|
||||
<mdui-dialog close-on-overlay-click id="ErrorDialog">
|
||||
<span slot="headline">错误</span>
|
||||
<span slot="description" id="ErrorDialog_Message"></span>
|
||||
</mdui-dialog>
|
||||
|
||||
<script nomodule>
|
||||
alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(')
|
||||
</script>
|
||||
<script type="module" src="./index.ts"></script>
|
||||
<script type="module" src="./init.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,23 +1,30 @@
|
||||
import 'mdui/mdui.css'
|
||||
import 'mdui'
|
||||
import { $ } from "mdui/jq"
|
||||
import { breakpoint, Dialog } from "mdui"
|
||||
import { breakpoint } from "mdui"
|
||||
|
||||
import './env.d.ts'
|
||||
|
||||
import * as React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
// deno-lint-ignore no-window no-window-prefix
|
||||
new URL(location.href).searchParams.get('debug') == 'true' && window.addEventListener('error', ({ message, filename, lineno, colno, error }) => {
|
||||
const m = $("#ErrorDialog_Message")
|
||||
const d = $("#ErrorDialog").get(0) as Dialog
|
||||
const s = d.open
|
||||
d.open = true
|
||||
m.html((s ? `${m.html()}<br/><br/>` : '') + `${message} (${filename || 'unknown'}:${lineno}:${colno})`)
|
||||
})
|
||||
import './ui/chat-elements/chat-image.ts'
|
||||
import './ui/chat-elements/chat-video.ts'
|
||||
import './ui/chat-elements/chat-file.ts'
|
||||
import './ui/chat-elements/chat-text.ts'
|
||||
import './ui/chat-elements/chat-mention.ts'
|
||||
import './ui/chat-elements/chat-text-container.ts'
|
||||
import './ui/chat-elements/chat-quote.ts'
|
||||
import Main from "./ui/Main.tsx"
|
||||
|
||||
import App from './ui/App.tsx'
|
||||
import performAuth from './performAuth.ts'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(App, null))
|
||||
try {
|
||||
await performAuth({})
|
||||
} catch (e) {
|
||||
console.log("验证失败", e)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(Main))
|
||||
|
||||
const onResize = () => {
|
||||
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
|
||||
@@ -29,3 +36,6 @@ const onResize = () => {
|
||||
// deno-lint-ignore no-window no-window-prefix
|
||||
window.addEventListener('resize', onResize)
|
||||
onResize()
|
||||
|
||||
const config = await fetch('config.json').then((re) => re.json())
|
||||
config.title && (document.title = config.title)
|
||||
32
client/package.json
Normal file
32
client/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "lingchair-client",
|
||||
"type": "module",
|
||||
"version": "0.1.0-alpha",
|
||||
"scripts": {
|
||||
"build": "npx vite build",
|
||||
"build-watch": "npx vite --watch build"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"lingchair-internal-shared": "*",
|
||||
"marked": "16.3.0",
|
||||
"mdui": "2.1.4",
|
||||
"pinch-zoom-element": "1.1.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.10.1",
|
||||
"socket.io-client": "4.8.1",
|
||||
"split.js": "1.3.2",
|
||||
"ua-parser-js": "2.0.6",
|
||||
"use-context-selector": "2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/wasm-node": "4.48.0",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"chalk": "5.4.1",
|
||||
"vite": "7.2.6",
|
||||
"vite-plugin-node-polyfills": "^0.24.0"
|
||||
}
|
||||
}
|
||||
31
client/performAuth.ts
Normal file
31
client/performAuth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import data from "./data.ts"
|
||||
import getClient from "./getClient.ts"
|
||||
|
||||
/**
|
||||
* 进行身份验证以接受客户端事件
|
||||
*
|
||||
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
|
||||
*
|
||||
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
|
||||
*
|
||||
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
|
||||
*
|
||||
* 多个验证方式不会逐一尝试
|
||||
*/
|
||||
export default async function performAuth(args: {
|
||||
refresh_token?: string
|
||||
account?: string
|
||||
password?: string
|
||||
}) {
|
||||
if (args.account && args.password)
|
||||
await getClient().authOrThrow({
|
||||
account: args.account,
|
||||
password: args.password,
|
||||
})
|
||||
else {
|
||||
await getClient().authOrThrow({ refresh_token: args.refresh_token ? args.refresh_token : data.refresh_token, ignore_all_empty: true })
|
||||
}
|
||||
data.refresh_token = getClient().getCachedRefreshToken()
|
||||
data.access_token = getClient().getCachedAccessToken()
|
||||
data.apply()
|
||||
}
|
||||
@@ -51,10 +51,10 @@ html {
|
||||
|
||||
/* 防止小尺寸图片模糊*/
|
||||
* {
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -o-crisp-edges;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
}
|
||||
|
||||
@@ -67,3 +67,7 @@ html {
|
||||
.gutter.gutter-horizontal {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--mdui-color-primary));
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="mdui-theme-auto">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no" />
|
||||
<meta name="renderer" content="webkit" />
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css">
|
||||
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
||||
|
||||
<title>TheWhiteSilk Debugger</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<mdui-button id="send">Send</mdui-button>
|
||||
<mdui-text-field id="edittext" autosize></mdui-text-field>
|
||||
<div id="out">
|
||||
|
||||
</div>
|
||||
<script>
|
||||
const socket = io()
|
||||
$('#edittext').val(`{
|
||||
"method": "",
|
||||
"args": {
|
||||
|
||||
}
|
||||
}`)
|
||||
$('#send').click(() => {
|
||||
socket.emit("the_white_silk", JSON.parse($('#edittext').val()), (response) => {
|
||||
$('#out').text(JSON.stringify(response))
|
||||
});
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
18
client/tsconfig.json
Normal file
18
client/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import Client from "../api/Client.ts"
|
||||
import data from "../Data.ts"
|
||||
import ChatFragment from "./chat/ChatFragment.jsx"
|
||||
import LoginDialog from "./dialog/LoginDialog.tsx"
|
||||
import ContactsListItem from "./main/ContactsListItem.jsx"
|
||||
import RecentsListItem from "./main/RecentsListItem.jsx"
|
||||
import useEventListener from './useEventListener.ts'
|
||||
import User from "../api/client_data/User.ts"
|
||||
import RecentChat from "../api/client_data/RecentChat.ts"
|
||||
|
||||
import * as React from 'react'
|
||||
import { Button, Dialog, NavigationRail, TextField } from "mdui"
|
||||
import Split from 'split.js'
|
||||
import 'mdui/jsx.zh-cn.d.ts'
|
||||
import { checkApiSuccessOrSncakbar } from "./snackbar.ts";
|
||||
import RegisterDialog from "./dialog/RegisterDialog.tsx";
|
||||
|
||||
declare global {
|
||||
namespace React {
|
||||
namespace JSX {
|
||||
interface IntrinsicAttributes {
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [recentsList, setRecentsList] = React.useState([
|
||||
{
|
||||
id: '0',
|
||||
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
|
||||
title: "麻油衣酱",
|
||||
content: "成步堂君, 我又坐牢了("
|
||||
},
|
||||
{
|
||||
id: '0',
|
||||
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
|
||||
title: "Maya Fey",
|
||||
content: "我是绫里真宵, 是一名灵媒师~"
|
||||
},
|
||||
] as RecentChat[])
|
||||
const [contactsMap, setContactsMap] = React.useState({
|
||||
所有: [
|
||||
{
|
||||
id: '0',
|
||||
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
|
||||
nickname: "麻油衣酱",
|
||||
},
|
||||
{
|
||||
id: '0',
|
||||
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
|
||||
nickname: "Maya Fey",
|
||||
},
|
||||
],
|
||||
} as unknown as { [key: string]: User[] })
|
||||
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
|
||||
|
||||
const navigationRailRef: React.MutableRefObject<NavigationRail | null> = React.useRef(null)
|
||||
useEventListener(navigationRailRef as React.MutableRefObject<NavigationRail>, 'change', (event) => {
|
||||
setNavigationItemSelected((event.target as HTMLElement as NavigationRail).value as string)
|
||||
})
|
||||
|
||||
const loginDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
|
||||
const loginInputAccountRef: React.MutableRefObject<TextField | null> = React.useRef(null)
|
||||
const loginInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
|
||||
|
||||
const registerDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
|
||||
const registerInputUserNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
|
||||
const registerInputNickNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
|
||||
const registerInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
; (async () => {
|
||||
Split(['#SideBar', '#ChatFragment'], {
|
||||
sizes: [25, 75],
|
||||
minSize: [200, 400],
|
||||
gutterSize: 2,
|
||||
})
|
||||
|
||||
Client.connect()
|
||||
const re = await Client.invoke("User.auth", {
|
||||
access_token: data.access_token || '',
|
||||
})
|
||||
if (re.code == 401)
|
||||
loginDialogRef.current!.open = true
|
||||
else if (re.code != 200)
|
||||
if (checkApiSuccessOrSncakbar(re, "驗證失敗")) return
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
position: 'relative',
|
||||
width: 'calc(var(--whitesilk-window-width) - 80px)',
|
||||
height: 'var(--whitesilk-window-height)',
|
||||
}}>
|
||||
<LoginDialog
|
||||
loginDialogRef={loginDialogRef}
|
||||
loginInputAccountRef={loginInputAccountRef}
|
||||
loginInputPasswordRef={loginInputPasswordRef}
|
||||
registerDialogRef={registerDialogRef} />
|
||||
|
||||
<RegisterDialog
|
||||
registerDialogRef={registerDialogRef}
|
||||
registerInputUserNameRef={registerInputUserNameRef}
|
||||
registerInputNickNameRef={registerInputNickNameRef}
|
||||
registerInputPasswordRef={registerInputPasswordRef}
|
||||
loginInputAccountRef={loginInputAccountRef}
|
||||
loginInputPasswordRef={loginInputPasswordRef} />
|
||||
|
||||
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
|
||||
<mdui-button-icon icon="menu" slot="top"></mdui-button-icon>
|
||||
|
||||
<mdui-navigation-rail-item icon="watch_later--outlined" value="Recents"></mdui-navigation-rail-item>
|
||||
<mdui-navigation-rail-item icon="contacts--outlined" value="Contacts"></mdui-navigation-rail-item>
|
||||
|
||||
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
|
||||
</mdui-navigation-rail>
|
||||
{
|
||||
// 侧边列表
|
||||
}
|
||||
<div id="SideBar">
|
||||
{
|
||||
// 最近聊天
|
||||
<mdui-list style={{
|
||||
overflowY: 'auto',
|
||||
paddingRight: '10px',
|
||||
display: navigationItemSelected == "Recents" ? undefined : 'none'
|
||||
}}>
|
||||
{
|
||||
recentsList.map((v) =>
|
||||
<RecentsListItem
|
||||
key={v.id}
|
||||
nickName={v.title}
|
||||
avatar={v.avatar}
|
||||
content={v.content} />
|
||||
)
|
||||
}
|
||||
</mdui-list>
|
||||
}
|
||||
{
|
||||
// 联系人列表
|
||||
<mdui-list style={{
|
||||
overflowY: 'auto',
|
||||
paddingRight: '10px',
|
||||
display: navigationItemSelected == "Contacts" ? undefined : 'none'
|
||||
}}>
|
||||
<mdui-collapse accordion value={Object.keys(contactsMap)[0]}>
|
||||
{
|
||||
Object.keys(contactsMap).map((v) =>
|
||||
<mdui-collapse-item key={v} value={v}>
|
||||
<mdui-list-subheader slot="header">{v}</mdui-list-subheader>
|
||||
{
|
||||
contactsMap[v].map((v2) =>
|
||||
<ContactsListItem
|
||||
key={v2.id}
|
||||
nickName={v2.nickname}
|
||||
avatar={v2.avatar} />
|
||||
)
|
||||
}
|
||||
</mdui-collapse-item>
|
||||
)
|
||||
}
|
||||
</mdui-collapse>
|
||||
</mdui-list>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
// 聊天页面
|
||||
}
|
||||
<ChatFragment id="ChatFragment" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export default function Avatar({ src, text, icon = 'person', ...props } = {}) {
|
||||
return (
|
||||
src ? <mdui-avatar {...props}>
|
||||
<img src={src} alt={'(头像)' + text || ''} />
|
||||
</mdui-avatar>
|
||||
: (
|
||||
text ? <mdui-avatar {...props}>
|
||||
{
|
||||
text.substring(0, 0)
|
||||
}
|
||||
</mdui-avatar>
|
||||
: <mdui-avatar icon={icon} {...props} />
|
||||
)
|
||||
)
|
||||
}
|
||||
25
client/ui/Avatar.tsx
Normal file
25
client/ui/Avatar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
src?: string
|
||||
text?: string
|
||||
icon?: string
|
||||
avatarRef?: React.LegacyRef<HTMLElement>
|
||||
}
|
||||
|
||||
export default function Avatar({
|
||||
src,
|
||||
text,
|
||||
icon = 'person',
|
||||
avatarRef,
|
||||
...props
|
||||
}: Args) {
|
||||
if (src != null && src != '')
|
||||
return <mdui-avatar ref={avatarRef} {...props} src={src} />
|
||||
else if (text != null && text != '')
|
||||
return <mdui-avatar ref={avatarRef} {...props}>
|
||||
{
|
||||
text.substring(0, 1)
|
||||
}
|
||||
</mdui-avatar>
|
||||
else
|
||||
return <mdui-avatar icon={icon} ref={avatarRef} {...props} />
|
||||
}
|
||||
34
client/ui/AvatarMySelf.tsx
Normal file
34
client/ui/AvatarMySelf.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { UserMySelf } from "lingchair-client-protocol"
|
||||
import useAsyncEffect from "../utils/useAsyncEffect.ts"
|
||||
import Avatar from "./Avatar.tsx"
|
||||
import getClient from "../getClient.ts"
|
||||
import React from "react"
|
||||
import sleep from "../utils/sleep.ts"
|
||||
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
avatarRef?: React.LegacyRef<HTMLElement>
|
||||
}
|
||||
export default function AvatarMySelf({
|
||||
avatarRef,
|
||||
...props
|
||||
}: Args) {
|
||||
if (!avatarRef) avatarRef = React.useRef<HTMLElement>(null)
|
||||
const [args, setArgs] = React.useState<{
|
||||
text: string,
|
||||
src: string,
|
||||
}>({
|
||||
text: '',
|
||||
src: '',
|
||||
})
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
await sleep(200)
|
||||
const mySelf = await UserMySelf.getMySelfOrThrow(getClient())
|
||||
setArgs({
|
||||
text: mySelf.getNickName(),
|
||||
src: getClient().getUrlForFileByHash(mySelf.getAvatarFileHash(), '')!
|
||||
})
|
||||
})
|
||||
|
||||
return <Avatar avatarRef={avatarRef} {...props} text={args.text} src={args.src}></Avatar>
|
||||
}
|
||||
19
client/ui/ImageViewer.tsx
Normal file
19
client/ui/ImageViewer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Dialog } from 'mdui'
|
||||
import 'pinch-zoom-element'
|
||||
import React from "react"
|
||||
|
||||
export default function ImageViewer() {
|
||||
const dialogRef = React.useRef<Dialog>()
|
||||
|
||||
return <mdui-dialog ref={dialogRef} fullscreen="fullscreen">
|
||||
<mdui-button-icon icon="open_in_new"
|
||||
onclick="window.open(document.querySelector('#image-viewer-dialog-inner > *').src, '_blank')">
|
||||
</mdui-button-icon>
|
||||
<mdui-button-icon icon="close" onClick={() => dialogRef.current!.open = false}>
|
||||
</mdui-button-icon>
|
||||
{
|
||||
// @ts-ignore 注册了这个元素
|
||||
<pinch-zoom id="image-viewer-dialog-inner" style="width: var(--whitesilk-window-width); height: var(--whitesilk-window-height);"></pinch-zoom>
|
||||
}
|
||||
</mdui-dialog>
|
||||
}
|
||||
231
client/ui/Main.tsx
Normal file
231
client/ui/Main.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import isMobileUI from "../utils/isMobileUI.ts"
|
||||
import useEventListener from "../utils/useEventListener.ts"
|
||||
import AvatarMySelf from "./AvatarMySelf.tsx"
|
||||
import MainSharedContext from './MainSharedContext.ts'
|
||||
import * as React from 'react'
|
||||
import { BrowserRouter, Link, Outlet, Route, Routes } from "react-router"
|
||||
import LoginDialog from "./main-page/LoginDialog.tsx"
|
||||
import useAsyncEffect from "../utils/useAsyncEffect.ts"
|
||||
import performAuth from "../performAuth.ts"
|
||||
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
||||
import showCircleProgressDialog from "./showCircleProgressDialog.ts"
|
||||
import RegisterDialog from "./main-page/RegisterDialog.tsx"
|
||||
import sleep from "../utils/sleep.ts"
|
||||
import { $, NavigationDrawer } from "mdui"
|
||||
import getClient from "../getClient.ts"
|
||||
import showSnackbar from "../utils/showSnackbar.ts"
|
||||
import AllChatsList from "./main-page/AllChatsList.tsx"
|
||||
import FavouriteChatsList from "./main-page/FavouriteChatsList.tsx"
|
||||
import AddFavourtieChatDialog from "./main-page/AddFavourtieChatDialog.tsx"
|
||||
import RecentChatsList from "./main-page/RecentChatsList.tsx"
|
||||
import ChatInfoDialog from "./routers/ChatInfoDialog.tsx"
|
||||
|
||||
export default function Main() {
|
||||
const [myProfileCache, setMyProfileCache] = React.useState<UserMySelf>()
|
||||
|
||||
// 多页面切换
|
||||
const navigationRef = React.useRef<HTMLElement>()
|
||||
const [currentShowPage, setCurrentShowPage] = React.useState('Recents')
|
||||
type HTMLElementWithValue = HTMLElement & { value: string }
|
||||
useEventListener(navigationRef, 'change', (event) => {
|
||||
setCurrentShowPage((event.target as HTMLElementWithValue).value)
|
||||
})
|
||||
|
||||
const drawerRef = React.useRef<NavigationDrawer>()
|
||||
React.useEffect(() => {
|
||||
$(drawerRef.current!.shadowRoot).append(`
|
||||
<style>
|
||||
.panel {
|
||||
width: 17.5rem !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
`)
|
||||
}, [])
|
||||
|
||||
const [showLoginDialog, setShowLoginDialog] = React.useState(false)
|
||||
const [showRegisterDialog, setShowRegisterDialog] = React.useState(false)
|
||||
const [showAddFavourtieChatDialog, setShowAddFavourtieChatDialog] = React.useState(false)
|
||||
|
||||
const [currentSelectedChatId, setCurrentSelectedChatId] = React.useState('')
|
||||
|
||||
const [favouriteChats, setFavouriteChats] = React.useState<Chat[]>([])
|
||||
|
||||
const sharedContext = {
|
||||
functions_lazy: React.useRef({
|
||||
updateFavouriteChats: () => { },
|
||||
updateRecentChats: () => { },
|
||||
updateAllChats: () => { },
|
||||
}),
|
||||
favouriteChats,
|
||||
setFavouriteChats,
|
||||
|
||||
setShowLoginDialog,
|
||||
setShowRegisterDialog,
|
||||
setShowAddFavourtieChatDialog,
|
||||
|
||||
currentSelectedChatId,
|
||||
setCurrentSelectedChatId,
|
||||
|
||||
myProfileCache,
|
||||
|
||||
}
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const waitingForAuth = showCircleProgressDialog("加载中...")
|
||||
try {
|
||||
await performAuth({})
|
||||
|
||||
try {
|
||||
setMyProfileCache(await UserMySelf.getMySelfOrThrow(getClient()))
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
showSnackbar({
|
||||
message: '获取资料失败: ' + e.message
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
if (e.code == 401 || e.code == 400)
|
||||
setShowLoginDialog(true)
|
||||
}
|
||||
// 动画都没来得及, 稍微等一下 (
|
||||
await sleep(100)
|
||||
waitingForAuth.open = false
|
||||
})
|
||||
|
||||
const subRoutes = <>
|
||||
<Route path="/info">
|
||||
<Route path="chat" element={<ChatInfoDialog />} />
|
||||
<Route path="user" element={<ChatInfoDialog />} />
|
||||
</Route>
|
||||
</>
|
||||
|
||||
return (
|
||||
<MainSharedContext.Provider value={sharedContext}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={(
|
||||
<div style={{
|
||||
display: "flex",
|
||||
position: 'relative',
|
||||
flexDirection: isMobileUI() ? 'column' : 'row',
|
||||
width: `calc(var(--whitesilk-window-width))${isMobileUI() ? '' : ' - 80px'}`,
|
||||
height: 'var(--whitesilk-window-height)',
|
||||
}}>
|
||||
{
|
||||
// 将子路由渲染到此处
|
||||
<Outlet />
|
||||
}
|
||||
<LoginDialog open={showLoginDialog} />
|
||||
<RegisterDialog open={showRegisterDialog} />
|
||||
<AddFavourtieChatDialog open={showAddFavourtieChatDialog} />
|
||||
<mdui-navigation-drawer ref={drawerRef} modal close-on-esc close-on-overlay-click>
|
||||
<mdui-list style={{
|
||||
padding: '10px',
|
||||
}}>
|
||||
<mdui-list-item rounded>
|
||||
<span>{myProfileCache?.getNickName()}</span>
|
||||
<AvatarMySelf slot="icon" />
|
||||
</mdui-list-item>
|
||||
<mdui-list-item rounded icon="manage_accounts">账号设置</mdui-list-item>
|
||||
<mdui-divider style={{
|
||||
margin: '10px',
|
||||
}}></mdui-divider>
|
||||
<mdui-list-item rounded icon="person_add">添加收藏对话</mdui-list-item>
|
||||
<mdui-list-item rounded icon="group_add">创建新的群组</mdui-list-item>
|
||||
<Link to="/info/user?id=0960bd15-4527-4000-97a8-73110160296f"><mdui-list-item rounded icon="group_add">我是测试</mdui-list-item></Link>
|
||||
<Link to="/info/chat?id=priv_0960bd15_4527_4000_97a8_73110160296f__0960bd15_4527_4000_97a8_73110160296f"><mdui-list-item rounded icon="group_add">我是测试2</mdui-list-item></Link>
|
||||
</mdui-list>
|
||||
<div style={{
|
||||
flexGrow: 1,
|
||||
}}></div>
|
||||
<span style={{
|
||||
padding: '10px',
|
||||
fontSize: 'small',
|
||||
}}>
|
||||
LingChair Web v{__APP_VERSION__}<br />
|
||||
Build: <a href={`https://codeberg.org/CrescentLeaf/LingChair/src/commit/${__GIT_HASH_FULL__}`}>{__GIT_HASH__}</a> ({__BUILD_TIME__})<br />
|
||||
在 Codeberg 上<a href="https://codeberg.org/CrescentLeaf/LingChair">查看源代码</a>
|
||||
</span>
|
||||
</mdui-navigation-drawer>
|
||||
{
|
||||
/**
|
||||
* Default: 侧边列表提供列表切换
|
||||
*/
|
||||
!isMobileUI() ?
|
||||
<mdui-navigation-rail ref={navigationRef} contained value="Recents">
|
||||
<mdui-button-icon slot="top" icon="menu" onClick={() => drawerRef.current!.open = true}></mdui-button-icon>
|
||||
|
||||
<mdui-navigation-rail-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-rail-item>
|
||||
<mdui-navigation-rail-item icon="favorite_border" active-icon="favorite" value="Favourites"></mdui-navigation-rail-item>
|
||||
<mdui-navigation-rail-item icon="chat--outlined" active-icon="chat--filled" value="AllChats"></mdui-navigation-rail-item>
|
||||
</mdui-navigation-rail>
|
||||
/**
|
||||
* Mobile: 底部导航栏提供列表切换
|
||||
*/
|
||||
: <mdui-top-app-bar style={{
|
||||
position: 'sticky',
|
||||
marginTop: '3px',
|
||||
marginRight: '6px',
|
||||
marginLeft: '15px',
|
||||
top: '0px',
|
||||
}}>
|
||||
<mdui-button-icon icon="menu" onClick={() => drawerRef.current!.open = true}></mdui-button-icon>
|
||||
<mdui-top-app-bar-title>{
|
||||
({
|
||||
Recents: "最近对话",
|
||||
Favourites: "收藏对话",
|
||||
AllChats: "所有对话",
|
||||
})[currentShowPage]
|
||||
}</mdui-top-app-bar-title>
|
||||
<div style={{
|
||||
flexGrow: 1,
|
||||
}}></div>
|
||||
</mdui-top-app-bar>
|
||||
}
|
||||
{
|
||||
/**
|
||||
* Mobile: 指定高度的容器
|
||||
* Default: 侧边列表
|
||||
*/
|
||||
<div style={isMobileUI() ? {
|
||||
display: 'flex',
|
||||
height: 'calc(100% - 80px - 67px)',
|
||||
width: '100%',
|
||||
} : {}} id="SideBar">
|
||||
<RecentChatsList style={{
|
||||
display: currentShowPage == 'Recents' ? undefined : 'none'
|
||||
}} />
|
||||
<FavouriteChatsList style={{
|
||||
display: currentShowPage == 'Favourites' ? undefined : 'none'
|
||||
}} />
|
||||
<AllChatsList style={{
|
||||
display: currentShowPage == 'AllChats' ? undefined : 'none'
|
||||
}} />
|
||||
</div>
|
||||
}
|
||||
{
|
||||
/**
|
||||
* Mobile: 底部导航栏提供列表切换
|
||||
* Default: 侧边列表提供列表切换
|
||||
*/
|
||||
isMobileUI() && <mdui-navigation-bar ref={navigationRef} label-visibility="selected" value="Recents" style={{
|
||||
position: 'sticky',
|
||||
bottom: '0',
|
||||
}}>
|
||||
<mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents">最近对话</mdui-navigation-bar-item>
|
||||
<mdui-navigation-bar-item icon="favorite_border" active-icon="favorite" value="Favourites">收藏对话</mdui-navigation-bar-item>
|
||||
<mdui-navigation-bar-item icon="chat--outlined" active-icon="chat--filled" value="AllChats">全部对话</mdui-navigation-bar-item>
|
||||
</mdui-navigation-bar>
|
||||
}
|
||||
</div>
|
||||
)}>
|
||||
{subRoutes}
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</MainSharedContext.Provider>
|
||||
)
|
||||
}
|
||||
23
client/ui/MainSharedContext.ts
Normal file
23
client/ui/MainSharedContext.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Chat, UserMySelf } from "lingchair-client-protocol"
|
||||
import { createContext } from "use-context-selector"
|
||||
|
||||
type Shared = {
|
||||
functions_lazy: React.MutableRefObject<{
|
||||
updateFavouriteChats: () => void
|
||||
updateRecentChats: () => void
|
||||
updateAllChats: () => void
|
||||
}>
|
||||
favouriteChats: Chat[]
|
||||
setFavouriteChats: React.Dispatch<React.SetStateAction<Chat[]>>
|
||||
setShowLoginDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setShowRegisterDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setShowAddFavourtieChatDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setCurrentSelectedChatId: React.Dispatch<React.SetStateAction<string>>
|
||||
myProfileCache?: UserMySelf
|
||||
currentSelectedChatId: string
|
||||
}
|
||||
const MainSharedContext = createContext({} as Shared)
|
||||
|
||||
export default MainSharedContext
|
||||
|
||||
export type { Shared }
|
||||
39
client/ui/chat-elements/chat-file.ts
Normal file
39
client/ui/chat-elements/chat-file.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { $ } from 'mdui/jq'
|
||||
|
||||
customElements.define('chat-file', class extends HTMLElement {
|
||||
static observedAttributes = ['href', 'name']
|
||||
declare anchor: HTMLAnchorElement
|
||||
declare span: HTMLSpanElement
|
||||
constructor() {
|
||||
super()
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
update() {
|
||||
if (this.anchor == null) return
|
||||
|
||||
this.anchor.href = $(this).attr('href') as string
|
||||
this.anchor.download = $(this).attr('href') as string
|
||||
this.span.textContent = $(this).attr("name") as string
|
||||
}
|
||||
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||
this.update()
|
||||
}
|
||||
connectedCallback() {
|
||||
this.anchor = new DOMParser().parseFromString(`
|
||||
<a style="width: 100%;height: 100%;">
|
||||
<mdui-card clickable style="display: flex;align-items: center;box-shadow: inherit;border-radius: inherit;">
|
||||
<mdui-icon name="insert_drive_file" style="margin: 13px;font-size: 34px;"></mdui-icon>
|
||||
<span style="margin-right: 13px; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
|
||||
</mdui-card>
|
||||
</a>`, 'text/html').body.firstChild as HTMLAnchorElement
|
||||
this.span = $(this.anchor).find('span').get(0)
|
||||
this.anchor.style.textDecoration = 'none'
|
||||
this.anchor.style.color = 'inherit'
|
||||
this.anchor.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
this.shadowRoot!.appendChild(this.anchor)
|
||||
|
||||
this.update()
|
||||
}
|
||||
})
|
||||
58
client/ui/chat-elements/chat-image.ts
Normal file
58
client/ui/chat-elements/chat-image.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import openImageViewer from "../../utils/openImageViewer.ts"
|
||||
|
||||
import { $ } from 'mdui/jq'
|
||||
|
||||
|
||||
customElements.define('chat-image', class extends HTMLElement {
|
||||
static observedAttributes = ['src', 'show-error']
|
||||
declare img: HTMLImageElement
|
||||
declare error: HTMLElement
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
update() {
|
||||
if (this.img == null) return
|
||||
|
||||
this.img.src = $(this).attr('src') as string
|
||||
|
||||
const error = $(this).attr('show-error') == 'true'
|
||||
this.img.style.display = error ? 'none' : 'block'
|
||||
this.error.style.display = error ? '' : 'none'
|
||||
}
|
||||
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||
this.update()
|
||||
}
|
||||
connectedCallback() {
|
||||
this.img = new Image()
|
||||
this.img.style.width = '100%'
|
||||
this.img.style.maxHeight = "300px"
|
||||
this.img.style.objectFit = 'cover'
|
||||
// this.img.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
||||
this.shadowRoot!.appendChild(this.img)
|
||||
|
||||
this.error = new DOMParser().parseFromString(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`, 'text/html').body.firstChild as HTMLElement
|
||||
this.shadowRoot!.appendChild(this.error)
|
||||
|
||||
this.img.addEventListener('error', () => {
|
||||
$(this).attr('show-error', 'true')
|
||||
})
|
||||
this.error.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
const img = this.img
|
||||
this.img = new Image()
|
||||
this.img.style.width = '100%'
|
||||
this.img.style.maxHeight = "300px"
|
||||
this.img.style.objectFit = 'cover'
|
||||
this.shadowRoot!.replaceChild(img, this.img)
|
||||
$(this).attr('show-error', undefined)
|
||||
})
|
||||
this.img.addEventListener('click', (event) => {
|
||||
event.stopPropagation()
|
||||
openImageViewer($(this).attr('src') as string)
|
||||
})
|
||||
|
||||
this.update()
|
||||
}
|
||||
})
|
||||
60
client/ui/chat-elements/chat-mention.ts
Normal file
60
client/ui/chat-elements/chat-mention.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { $ } from 'mdui'
|
||||
import showSnackbar from "../../utils/showSnackbar.ts";
|
||||
customElements.define('chat-mention', class extends HTMLElement {
|
||||
declare link: HTMLAnchorElement
|
||||
static observedAttributes = ['user-id']
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
connectedCallback() {
|
||||
const shadow = this.shadowRoot as ShadowRoot
|
||||
|
||||
this.link = document.createElement('a')
|
||||
this.link.style.fontSynthesis = 'style weight'
|
||||
this.link.style.color = 'rgb(var(--mdui-color-primary))'
|
||||
this.link.href = 'javascript:void(0)'
|
||||
shadow.appendChild(this.link)
|
||||
|
||||
this.update()
|
||||
}
|
||||
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||
this.update()
|
||||
}
|
||||
async update() {
|
||||
if (this.link == null) return
|
||||
|
||||
const userId = $(this).attr('user-id')
|
||||
const chatId = $(this).attr('chat-id')
|
||||
const text = $(this).attr('text')
|
||||
this.link.style.fontStyle = ''
|
||||
if (chatId) {
|
||||
|
||||
this.link.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
// deno-lint-ignore no-window
|
||||
|
||||
}
|
||||
} else if (userId) {
|
||||
|
||||
this.link.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
// deno-lint-ignore no-window
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
text && (this.link.textContent = text)
|
||||
if (!(userId || chatId)) {
|
||||
this.link.textContent = "无效的提及"
|
||||
this.link.style.fontStyle = 'italic'
|
||||
this.link.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
showSnackbar({
|
||||
message: "该提及没有指定用户或者对话!",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
45
client/ui/chat-elements/chat-quote.ts
Normal file
45
client/ui/chat-elements/chat-quote.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { $ } from 'mdui/jq'
|
||||
|
||||
customElements.define('chat-quote', class extends HTMLElement {
|
||||
declare container: HTMLAnchorElement
|
||||
declare span: HTMLSpanElement
|
||||
declare ellipsis: boolean
|
||||
constructor() {
|
||||
super()
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
update() {
|
||||
if (this.container == null) return
|
||||
|
||||
this.span.textContent = this.textContent
|
||||
|
||||
this.updateStyle()
|
||||
}
|
||||
updateStyle() {
|
||||
this.span.style.whiteSpace = this.ellipsis ? 'nowrap' : 'pre-wrap'
|
||||
this.span.style.overflow = this.ellipsis ? 'hidden' : ''
|
||||
this.span.style.textOverflow = this.ellipsis ? 'ellipsis' : ''
|
||||
}
|
||||
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||
this.update()
|
||||
}
|
||||
connectedCallback() {
|
||||
this.container = new DOMParser().parseFromString(`
|
||||
<a style="width: 100%;height: 100%; color: rgb(var(--mdui-color-primary));" href="javascript:void(0)">
|
||||
<span style="display: block; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
|
||||
</a>`, 'text/html').body.firstChild as HTMLAnchorElement
|
||||
this.span = $(this.container).find('span').get(0)
|
||||
this.container.style.textDecoration = 'none'
|
||||
this.span.style.fontSynthesis = 'style weight'
|
||||
this.container.onclick = (e) => {
|
||||
this.ellipsis = !this.ellipsis
|
||||
this.updateStyle()
|
||||
e.stopPropagation()
|
||||
}
|
||||
this.ellipsis = true
|
||||
|
||||
this.shadowRoot!.appendChild(this.container)
|
||||
|
||||
this.update()
|
||||
}
|
||||
})
|
||||
16
client/ui/chat-elements/chat-text-container.ts
Normal file
16
client/ui/chat-elements/chat-text-container.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
customElements.define('chat-text-container', class extends HTMLElement {
|
||||
declare container: HTMLDivElement
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
connectedCallback() {
|
||||
const shadow = this.shadowRoot as ShadowRoot
|
||||
|
||||
this.container = document.createElement('div')
|
||||
this.container.style.padding = '13px'
|
||||
shadow.appendChild(this.container)
|
||||
this.container.innerHTML = this.innerHTML
|
||||
}
|
||||
})
|
||||
40
client/ui/chat-elements/chat-text.ts
Normal file
40
client/ui/chat-elements/chat-text.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { $ } from 'mdui'
|
||||
|
||||
customElements.define('chat-text', class extends HTMLElement {
|
||||
declare span: HTMLSpanElement
|
||||
static observedAttributes = ['underline', 'em']
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
connectedCallback() {
|
||||
const shadow = this.shadowRoot as ShadowRoot
|
||||
|
||||
this.span = document.createElement('span')
|
||||
this.span.style.whiteSpace = 'pre-wrap'
|
||||
this.span.style.fontSynthesis = 'style weight'
|
||||
shadow.appendChild(this.span)
|
||||
|
||||
this.update()
|
||||
}
|
||||
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||
this.update()
|
||||
}
|
||||
update() {
|
||||
if (this.span == null) return
|
||||
|
||||
const isFirstElementInParent = this.parentElement?.firstElementChild == this
|
||||
const isLastElementInParent = this.parentElement?.lastElementChild == this
|
||||
|
||||
// 避免不同的消息类型之间的换行符导致显示异常
|
||||
if (isFirstElementInParent)
|
||||
this.span.textContent = this.textContent.trimStart()
|
||||
else if (isLastElementInParent)
|
||||
this.span.textContent = this.textContent.trimEnd()
|
||||
else
|
||||
this.span.textContent = this.textContent
|
||||
this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : ''
|
||||
this.span.style.fontStyle = $(this).attr('em') ? 'italic' : ''
|
||||
}
|
||||
})
|
||||
32
client/ui/chat-elements/chat-video.ts
Normal file
32
client/ui/chat-elements/chat-video.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { $ } from 'mdui/jq'
|
||||
|
||||
customElements.define('chat-video', class extends HTMLElement {
|
||||
static observedAttributes = ['src']
|
||||
declare video: HTMLVideoElement
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.attachShadow({ mode: 'open' })
|
||||
}
|
||||
update() {
|
||||
if (this.video == null) return
|
||||
|
||||
this.video.src = $(this).attr('src') as string
|
||||
}
|
||||
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||
this.update()
|
||||
}
|
||||
connectedCallback() {
|
||||
this.video = new DOMParser().parseFromString(`<video controls></video>`, 'text/html').body.firstChild as HTMLVideoElement
|
||||
this.video.style.maxWidth = "400px"
|
||||
this.video.style.maxHeight = "300px"
|
||||
this.video.style.width = "100%"
|
||||
this.video.style.height = "100%"
|
||||
this.video.style.display = 'block'
|
||||
// e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
||||
|
||||
this.video.onclick = (e) => e.stopPropagation()
|
||||
this.shadowRoot!.appendChild(this.video)
|
||||
this.update()
|
||||
}
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import Message from "./Message.jsx"
|
||||
import MessageContainer from "./MessageContainer.jsx"
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
export default function ChatFragment({ ...props } = {}) {
|
||||
const messageList = React.useState([])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflowY: 'auto',
|
||||
}} {...props}>
|
||||
<mdui-top-app-bar style={{
|
||||
position: 'sticky',
|
||||
}}>
|
||||
<mdui-button-icon icon="menu"></mdui-button-icon>
|
||||
<mdui-top-app-bar-title>Title</mdui-top-app-bar-title>
|
||||
<mdui-button-icon icon="more_vert"></mdui-button-icon>
|
||||
</mdui-top-app-bar>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<mdui-button variant="text">加載更多</mdui-button>
|
||||
</div>
|
||||
<MessageContainer>
|
||||
</MessageContainer>
|
||||
{
|
||||
// 输入框
|
||||
}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingBottom: '0.1rem',
|
||||
paddingTop: '0.1rem',
|
||||
height: '4rem',
|
||||
position: 'sticky',
|
||||
bottom: '2px',
|
||||
marginLeft: '5px',
|
||||
marginRight: '4px',
|
||||
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||
}}>
|
||||
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize max-rows="1" style={{
|
||||
marginRight: '10px',
|
||||
}}></mdui-text-field>
|
||||
<mdui-button-icon slot="end-icon" icon="more_vert" style={{
|
||||
marginRight: '6px',
|
||||
}}></mdui-button-icon>
|
||||
<mdui-button-icon icon="send" style={{
|
||||
marginRight: '7px',
|
||||
}}></mdui-button-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import Avatar from "../Avatar.jsx"
|
||||
|
||||
/**
|
||||
* 一条消息
|
||||
* @param { Object } param
|
||||
* @param { "left" | "right" } [param.direction="left"] 消息方向
|
||||
* @param { String } [param.avatar] 头像链接
|
||||
* @param { String } [param.nickName] 昵称
|
||||
* @returns { React.JSX.Element }
|
||||
*/
|
||||
export default function Message({ direction = 'left', avatar, nickName, children, ...props } = {}) {
|
||||
let isAtRight = direction == 'right'
|
||||
return (
|
||||
<div
|
||||
slot="trigger"
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: isAtRight ? "flex-end" : "flex-start",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
{...props}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: isAtRight ? "flex-end" : "flex-start",
|
||||
}}>
|
||||
{
|
||||
// 发送者昵称(左)
|
||||
isAtRight && <span
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
fontSize: "90%"
|
||||
}}>
|
||||
{nickName}
|
||||
</span>
|
||||
}
|
||||
{
|
||||
// 发送者头像
|
||||
}
|
||||
<Avatar
|
||||
src={avatar}
|
||||
text={nickName}
|
||||
style={{
|
||||
width: "43px",
|
||||
height: "43px",
|
||||
margin: "11px"
|
||||
}} />
|
||||
{
|
||||
// 发送者昵称(右)
|
||||
!isAtRight && <span
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
fontSize: "90%"
|
||||
}}>
|
||||
{nickName}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<mdui-card
|
||||
variant="elevated"
|
||||
style={{
|
||||
maxWidth: 'var(--whitesilk-widget-message-maxwidth)', // (window.matchMedia('(pointer: fine)') && "50%") || (window.matchMedia('(pointer: coarse)') && "77%"),
|
||||
minWidth: "0%",
|
||||
[isAtRight ? "marginRight" : "marginLeft"]: "55px",
|
||||
marginTop: "-5px",
|
||||
padding: "15px",
|
||||
alignSelf: isAtRight ? "flex-end" : "flex-start",
|
||||
}}>
|
||||
<span
|
||||
id="msg"
|
||||
style={{
|
||||
fontSize: "94%"
|
||||
}}>
|
||||
{
|
||||
// 消息内容
|
||||
children
|
||||
}
|
||||
</span>
|
||||
</mdui-card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* 消息容器
|
||||
* @returns { React.JSX.Element }
|
||||
*/
|
||||
export default function MessageContainer({ children, style, ...props } = {}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
height: "100%",
|
||||
...style,
|
||||
}}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* 一条系统提示消息
|
||||
* @returns { React.JSX.Element }
|
||||
*/
|
||||
export default function SystemMessage({ children } = {}) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
display: 'flex',
|
||||
marginTop: '25px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<mdui-card variant="filled"
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
paddingTop: '9px',
|
||||
paddingBottom: '9px',
|
||||
paddingLeft: '18px',
|
||||
paddingRight: '18px',
|
||||
fontSize: '92%',
|
||||
}}>
|
||||
{children}
|
||||
</mdui-card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Button, Dialog, TextField } from "mdui"
|
||||
import useEventListener from "../useEventListener.ts"
|
||||
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
|
||||
import Client from "../../api/Client.ts"
|
||||
|
||||
import * as CryptoJS from 'crypto-js'
|
||||
import data from "../../Data.ts";
|
||||
|
||||
interface Refs {
|
||||
loginInputAccountRef: React.MutableRefObject<TextField | null>
|
||||
loginInputPasswordRef: React.MutableRefObject<TextField | null>
|
||||
loginDialogRef: React.MutableRefObject<Dialog | null>
|
||||
registerDialogRef: React.MutableRefObject<Dialog | null>
|
||||
}
|
||||
|
||||
export default function LoginDialog({
|
||||
loginInputAccountRef,
|
||||
loginInputPasswordRef,
|
||||
loginDialogRef,
|
||||
registerDialogRef
|
||||
}: Refs) {
|
||||
const loginButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
|
||||
const registerButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
|
||||
useEventListener(registerButtonRef, 'click', () => registerDialogRef.current!.open = true)
|
||||
useEventListener(loginButtonRef, 'click', async () => {
|
||||
const account = loginInputAccountRef.current!.value
|
||||
const password = loginInputPasswordRef.current!.value
|
||||
|
||||
const re = await Client.invoke("User.login", {
|
||||
account: account,
|
||||
password: CryptoJS.SHA256(password).toString(CryptoJS.enc.Hex),
|
||||
})
|
||||
|
||||
if (checkApiSuccessOrSncakbar(re, "登錄失敗")) return
|
||||
|
||||
data.access_token = re.data!.access_token as string
|
||||
data.apply()
|
||||
location.reload()
|
||||
})
|
||||
return (
|
||||
<mdui-dialog headline="登錄" ref={loginDialogRef}>
|
||||
|
||||
<mdui-text-field label="用戶 ID / 用戶名" ref={loginInputAccountRef}></mdui-text-field>
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
<mdui-text-field label="密碼" type="password" toggle-password ref={loginInputPasswordRef}></mdui-text-field>
|
||||
|
||||
<mdui-button slot="action" variant="text" ref={registerButtonRef}>注册</mdui-button>
|
||||
<mdui-button slot="action" variant="text" ref={loginButtonRef}>登录</mdui-button>
|
||||
</mdui-dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Button, Dialog, TextField } from "mdui";
|
||||
import useEventListener from "../useEventListener.ts";
|
||||
import Client from "../../api/Client.ts";
|
||||
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts";
|
||||
|
||||
import * as CryptoJS from 'crypto-js'
|
||||
|
||||
interface Refs {
|
||||
loginInputAccountRef: React.MutableRefObject<TextField | null>
|
||||
loginInputPasswordRef: React.MutableRefObject<TextField | null>
|
||||
registerInputUserNameRef: React.MutableRefObject<TextField | null>
|
||||
registerInputNickNameRef: React.MutableRefObject<TextField | null>
|
||||
registerInputPasswordRef: React.MutableRefObject<TextField | null>
|
||||
registerDialogRef: React.MutableRefObject<Dialog | null>
|
||||
}
|
||||
|
||||
export default function RegisterDialog({
|
||||
loginInputAccountRef,
|
||||
loginInputPasswordRef,
|
||||
registerInputUserNameRef,
|
||||
registerInputNickNameRef,
|
||||
registerInputPasswordRef,
|
||||
registerDialogRef
|
||||
}: Refs) {
|
||||
const registerBackButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
|
||||
const doRegisterButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
|
||||
useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false)
|
||||
useEventListener(doRegisterButtonRef, 'click', async () => {
|
||||
const username = registerInputUserNameRef.current!.value
|
||||
const re = await Client.invoke("User.register", {
|
||||
username: username,
|
||||
nickname: registerInputNickNameRef.current!.value,
|
||||
password: CryptoJS.SHA256(registerInputPasswordRef.current!.value).toString(CryptoJS.enc.Hex),
|
||||
})
|
||||
|
||||
if (checkApiSuccessOrSncakbar(re, "注冊失敗")) return
|
||||
loginInputAccountRef.current!.value = username == "" ? re.data!.userid as string : username
|
||||
loginInputPasswordRef.current!.value = registerInputPasswordRef.current!.value
|
||||
|
||||
registerInputUserNameRef.current!.value = ""
|
||||
registerInputNickNameRef.current!.value = ""
|
||||
registerInputPasswordRef.current!.value = ""
|
||||
registerDialogRef.current!.open = false
|
||||
snackbar({
|
||||
message: "注冊成功!",
|
||||
placement: "top",
|
||||
})
|
||||
})
|
||||
return (
|
||||
<mdui-dialog headline="注冊" ref={registerDialogRef}>
|
||||
|
||||
<mdui-text-field label="用戶名 (可選)" ref={registerInputUserNameRef}></mdui-text-field>
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
<mdui-text-field label="昵稱" ref={registerInputNickNameRef}></mdui-text-field>
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef}></mdui-text-field>
|
||||
|
||||
<mdui-button slot="action" variant="text" ref={registerBackButtonRef}>返回</mdui-button>
|
||||
<mdui-button slot="action" variant="text" ref={doRegisterButtonRef}>注冊</mdui-button>
|
||||
</mdui-dialog>
|
||||
)
|
||||
}
|
||||
46
client/ui/main-page/AddFavourtieChatDialog.tsx
Normal file
46
client/ui/main-page/AddFavourtieChatDialog.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { Button, Dialog, snackbar, TextField } from "mdui"
|
||||
import { data } from 'react-router'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import MainSharedContext, { Shared } from '../MainSharedContext'
|
||||
import showSnackbar from '../../utils/showSnackbar'
|
||||
import { CallbackError } from 'lingchair-client-protocol'
|
||||
import useEventListener from '../../utils/useEventListener'
|
||||
|
||||
export default function AddFavourtieChatDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
myProfileCache: context.myProfileCache,
|
||||
setShowAddFavourtieChatDialog: context.setShowAddFavourtieChatDialog,
|
||||
}))
|
||||
|
||||
const dialogRef = React.useRef<Dialog>()
|
||||
useEventListener(dialogRef, 'closed', () => shared.setShowAddFavourtieChatDialog(false))
|
||||
|
||||
const inputTargetRef = React.useRef<TextField>(null)
|
||||
|
||||
async function addFavouriteChat() {
|
||||
try {
|
||||
shared.myProfileCache!.addFavouriteChatsOrThrow([inputTargetRef.current!.value])
|
||||
inputTargetRef.current!.value = ''
|
||||
showSnackbar({
|
||||
message: '添加成功!'
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
showSnackbar({
|
||||
message: '添加收藏对话失败: ' + e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<mdui-dialog close-on-overlay-click close-on-esc headline="添加收藏对话" {...props} ref={dialogRef}>
|
||||
<mdui-text-field clearable label="对话 / 用户 (ID 或 别名)" ref={inputTargetRef} onKeyDown={(event: KeyboardEvent) => {
|
||||
if (event.key == 'Enter')
|
||||
addFavouriteChat()
|
||||
}}></mdui-text-field>
|
||||
<mdui-button slot="action" variant="text" onClick={() => shared.setShowAddFavourtieChatDialog(false)}>取消</mdui-button>
|
||||
<mdui-button slot="action" variant="text" onClick={() => addFavouriteChat()}>添加</mdui-button>
|
||||
</mdui-dialog>
|
||||
)
|
||||
}
|
||||
80
client/ui/main-page/AllChatsList.tsx
Normal file
80
client/ui/main-page/AllChatsList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { TextField } from "mdui"
|
||||
import React from "react"
|
||||
import AllChatsListItem from "./AllChatsListItem.tsx"
|
||||
import useEventListener from "../../utils/useEventListener.ts"
|
||||
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
||||
import getClient from "../../getClient.ts"
|
||||
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||
import { useContextSelector } from "use-context-selector"
|
||||
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||
|
||||
export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
myProfileCache: context.myProfileCache,
|
||||
functions_lazy: context.functions_lazy,
|
||||
}))
|
||||
|
||||
const searchRef = React.useRef<HTMLElement>(null)
|
||||
const [searchText, setSearchText] = React.useState('')
|
||||
const [allChatsList, setAllChatsList] = React.useState<Chat[]>([])
|
||||
|
||||
useEventListener(searchRef, 'input', (e) => {
|
||||
setSearchText((e.target as unknown as TextField).value)
|
||||
})
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
async function updateAllChats() {
|
||||
try {
|
||||
setAllChatsList(await shared.myProfileCache!.getMyAllChatsOrThrow())
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
if (e.code != 401 && e.code != 400)
|
||||
showSnackbar({
|
||||
message: '获取所有对话失败: ' + e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
updateAllChats()
|
||||
|
||||
shared.functions_lazy.current.updateAllChats = updateAllChats
|
||||
|
||||
return () => {
|
||||
}
|
||||
})
|
||||
|
||||
return <mdui-list style={{
|
||||
overflowY: 'auto',
|
||||
paddingRight: '10px',
|
||||
paddingLeft: '10px',
|
||||
paddingTop: '0',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
...props?.style,
|
||||
}} {...props}>
|
||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '13px',
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||
zIndex: '10',
|
||||
}}></mdui-text-field>
|
||||
{
|
||||
allChatsList.filter((chat) =>
|
||||
searchText == '' ||
|
||||
chat.getTitle().includes(searchText) ||
|
||||
chat.getId().includes(searchText)
|
||||
).map((v) =>
|
||||
<AllChatsListItem
|
||||
active={isMobileUI() ? false : currentChatId == v.getId()}
|
||||
key={v.getId()}
|
||||
onClick={() => {
|
||||
openChatInfoDialog(v)
|
||||
}}
|
||||
chat={v} />
|
||||
)
|
||||
}
|
||||
</mdui-list>
|
||||
}
|
||||
29
client/ui/main-page/AllChatsListItem.tsx
Normal file
29
client/ui/main-page/AllChatsListItem.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { $ } from "mdui/jq"
|
||||
import Avatar from "../Avatar.tsx"
|
||||
import React from 'react'
|
||||
import { Chat } from "lingchair-client-protocol"
|
||||
import getClient from "../../getClient.ts"
|
||||
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
chat: Chat
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export default function AllChatsListItem({ chat, active, ...prop }: Args) {
|
||||
const title = chat.getTitle()
|
||||
|
||||
const ref = React.useRef<HTMLElement>(null)
|
||||
|
||||
return (
|
||||
<mdui-list-item active={active} ref={ref} rounded style={{
|
||||
marginTop: '3px',
|
||||
marginBottom: '3px',
|
||||
width: '100%',
|
||||
}} {...prop as any}>
|
||||
<span style={{
|
||||
width: "100%",
|
||||
}}>{title}</span>
|
||||
<Avatar src={getClient().getUrlForFileByHash(chat.getAvatarFileHash() as string)} text={title} slot="icon" />
|
||||
</mdui-list-item>
|
||||
)
|
||||
}
|
||||
170
client/ui/main-page/FavouriteChatsList.tsx
Normal file
170
client/ui/main-page/FavouriteChatsList.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from "react"
|
||||
import FavouriteChatsListItem from "./FavouriteChatsListItem.tsx"
|
||||
import { dialog, TextField } from "mdui"
|
||||
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||
import useEventListener from "../../utils/useEventListener.ts"
|
||||
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
||||
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||
import getClient from "../../getClient.ts"
|
||||
import { useContextSelector } from "use-context-selector"
|
||||
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||
|
||||
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
myProfileCache: context.myProfileCache,
|
||||
setShowAddFavourtieChatDialog: context.setShowAddFavourtieChatDialog,
|
||||
functions_lazy: context.functions_lazy,
|
||||
currentSelectedChatId: context.currentSelectedChatId,
|
||||
values_lazy: context.values_lazy,
|
||||
}))
|
||||
|
||||
const searchRef = React.useRef<HTMLElement>(null)
|
||||
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
|
||||
const [searchText, setSearchText] = React.useState('')
|
||||
const [favouriteChatsList, setFavouriteChatsList] = React.useState<Chat[]>([])
|
||||
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
|
||||
|
||||
useEventListener(searchRef, 'input', (e) => {
|
||||
setSearchText((e.target as unknown as TextField).value)
|
||||
})
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
async function updateFavouriteChats() {
|
||||
try {
|
||||
const ls = await shared.myProfileCache!.getMyFavouriteChatsOrThrow()
|
||||
setFavouriteChatsList(ls)
|
||||
shared.favourite_chats
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
if (e.code != 401 && e.code != 400)
|
||||
showSnackbar({
|
||||
message: '获取收藏对话失败: ' + e.message
|
||||
})
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
updateFavouriteChats()
|
||||
|
||||
shared.functions_lazy.current.updateFavouriteChats = updateFavouriteChats
|
||||
|
||||
return () => {
|
||||
}
|
||||
}, [shared.myProfileCache])
|
||||
|
||||
return <mdui-list style={{
|
||||
overflowY: 'auto',
|
||||
paddingLeft: '10px',
|
||||
paddingRight: '10px',
|
||||
paddingTop: '0',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
...props?.style,
|
||||
}} {...props}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||
zIndex: '10',
|
||||
}}>
|
||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||
paddingTop: '12px',
|
||||
}}></mdui-text-field>
|
||||
<mdui-list-item rounded style={{
|
||||
marginTop: '13px',
|
||||
width: '100%',
|
||||
}} icon="person_add" onClick={() => shared.setShowAddFavourtieChatDialog(true)}>添加收藏</mdui-list-item>
|
||||
<mdui-list-item rounded style={{
|
||||
width: '100%',
|
||||
}} icon="refresh" onClick={() => shared.functions_lazy.current.updateFavouriteChats()}>刷新列表</mdui-list-item>
|
||||
<mdui-list-item rounded style={{
|
||||
width: '100%',
|
||||
}} icon={isMultiSelecting ? "done" : "edit"} onClick={() => {
|
||||
if (isMultiSelecting)
|
||||
setCheckedList({})
|
||||
setIsMultiSelecting(!isMultiSelecting)
|
||||
}}>{isMultiSelecting ? "关闭多选" : "多选模式"}</mdui-list-item>
|
||||
{
|
||||
isMultiSelecting && <>
|
||||
<mdui-list-item rounded style={{
|
||||
width: '100%',
|
||||
}} icon="delete" onClick={() => dialog({
|
||||
headline: "移除收藏对话",
|
||||
description: "确定将所选对话从收藏中移除吗? 这不会导致对话被删除.",
|
||||
closeOnEsc: true,
|
||||
closeOnOverlayClick: true,
|
||||
actions: [
|
||||
{
|
||||
text: "取消",
|
||||
onClick: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "确定",
|
||||
onClick: async () => {
|
||||
const ls = Object.keys(checkedList).filter((chatId) => checkedList[chatId] == true)
|
||||
|
||||
try {
|
||||
shared.myProfileCache!.removeFavouriteChatsOrThrow(ls)
|
||||
|
||||
setCheckedList({})
|
||||
setIsMultiSelecting(false)
|
||||
|
||||
shared.functions_lazy.current.updateFavouriteChats()
|
||||
|
||||
showSnackbar({
|
||||
message: "已删除所选",
|
||||
action: "撤销操作",
|
||||
onActionClick: async () => {
|
||||
try {
|
||||
shared.myProfileCache!.addFavouriteChatsOrThrow(ls)
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
showSnackbar({
|
||||
message: '撤销删除收藏失败: ' + e.message
|
||||
})
|
||||
}
|
||||
shared.functions_lazy.current.updateFavouriteChats()
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
showSnackbar({
|
||||
message: '删除收藏对话失败: ' + e.message
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
})}>删除所选</mdui-list-item>
|
||||
</>
|
||||
}
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
</div>
|
||||
|
||||
{
|
||||
favouriteChatsList.filter((chat) =>
|
||||
searchText == '' ||
|
||||
chat.getTitle().includes(searchText) ||
|
||||
chat.getId().includes(searchText)
|
||||
).map((v) =>
|
||||
<FavouriteChatsListItem
|
||||
active={isMultiSelecting ? checkedList[v.getId()] == true : (isMobileUI() ? false : shared.currentSelectedChatId == v.getId())}
|
||||
onClick={() => {
|
||||
if (isMultiSelecting)
|
||||
setCheckedList({
|
||||
...checkedList,
|
||||
[v.getId()]: !checkedList[v.getId()],
|
||||
})
|
||||
else
|
||||
openChatInfoDialog(v)
|
||||
}}
|
||||
key={v.getId()}
|
||||
chat={v} />
|
||||
)
|
||||
}
|
||||
</mdui-list>
|
||||
}
|
||||
28
client/ui/main-page/FavouriteChatsListItem.tsx
Normal file
28
client/ui/main-page/FavouriteChatsListItem.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Chat } from "lingchair-client-protocol"
|
||||
import Avatar from "../Avatar.tsx"
|
||||
import React from 'react'
|
||||
import getClient from "../../getClient.ts"
|
||||
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
chat: Chat
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export default function FavouriteChatsListItem({ chat, active, ...prop }: Args) {
|
||||
const title = chat.getTitle()
|
||||
|
||||
const ref = React.useRef<HTMLElement>(null)
|
||||
|
||||
return (
|
||||
<mdui-list-item active={active} ref={ref} rounded style={{
|
||||
marginTop: '3px',
|
||||
marginBottom: '3px',
|
||||
width: '100%',
|
||||
}} {...prop}>
|
||||
<span style={{
|
||||
width: "100%",
|
||||
}}>{title}</span>
|
||||
<Avatar src={getClient().getUrlForFileByHash(chat.getAvatarFileHash() as string)} text={title} slot="icon" />
|
||||
</mdui-list-item>
|
||||
)
|
||||
}
|
||||
49
client/ui/main-page/LoginDialog.tsx
Normal file
49
client/ui/main-page/LoginDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import { Dialog, TextField } from "mdui"
|
||||
|
||||
import performAuth from '../../performAuth.ts'
|
||||
import showSnackbar from '../../utils/showSnackbar.ts'
|
||||
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import useEventListener from '../../utils/useEventListener.ts'
|
||||
|
||||
export default function LoginDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
setShowRegisterDialog: context.setShowRegisterDialog,
|
||||
setShowLoginDialog: context.setShowLoginDialog
|
||||
}))
|
||||
|
||||
const dialogRef = React.useRef<Dialog>()
|
||||
useEventListener(dialogRef, 'closed', () => shared.setShowLoginDialog(false))
|
||||
|
||||
const loginInputAccountRef = React.useRef<TextField>(null)
|
||||
const loginInputPasswordRef = React.useRef<TextField>(null)
|
||||
|
||||
return (
|
||||
<mdui-dialog {...props} headline="登录" ref={dialogRef}>
|
||||
|
||||
<mdui-text-field label="用户 ID / 用户名" ref={loginInputAccountRef}></mdui-text-field>
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
<mdui-text-field label="密码" type="password" toggle-password ref={loginInputPasswordRef}></mdui-text-field>
|
||||
|
||||
<mdui-button slot="action" variant="text" onClick={() => shared.setShowRegisterDialog(true)}>注册</mdui-button>
|
||||
<mdui-button slot="action" variant="text" onClick={async () => {
|
||||
const account = loginInputAccountRef.current!.value
|
||||
const password = loginInputPasswordRef.current!.value
|
||||
|
||||
try {
|
||||
await performAuth({
|
||||
account: account,
|
||||
password: password,
|
||||
})
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
if (e instanceof Error)
|
||||
showSnackbar({ message: '登录失败: ' + e.message })
|
||||
}
|
||||
}}>登录</mdui-button>
|
||||
</mdui-dialog>
|
||||
)
|
||||
}
|
||||
83
client/ui/main-page/RecentChatsList.tsx
Normal file
83
client/ui/main-page/RecentChatsList.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { TextField } from "mdui"
|
||||
import RecentsListItem from "./RecentsListItem.tsx"
|
||||
import React from "react"
|
||||
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
|
||||
import { data } from "react-router"
|
||||
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||
import useEventListener from "../../utils/useEventListener.ts"
|
||||
import { CallbackError } from "lingchair-client-protocol"
|
||||
import { useContextSelector } from "use-context-selector"
|
||||
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||
|
||||
export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
myProfileCache: context.myProfileCache,
|
||||
functions_lazy: context.functions_lazy,
|
||||
currentSelectedChatId: context.currentSelectedChatId,
|
||||
}))
|
||||
|
||||
const searchRef = React.useRef<HTMLElement>(null)
|
||||
const [searchText, setSearchText] = React.useState('')
|
||||
const [recentsList, setRecentsList] = React.useState<RecentChat[]>([])
|
||||
|
||||
useEventListener(searchRef, 'input', (e) => {
|
||||
setSearchText((e.target as unknown as TextField).value)
|
||||
})
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
async function updateRecents() {
|
||||
try {
|
||||
setRecentsList(await shared.myProfileCache!.getMyRecentChats())
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
if (e.code != 401 && e.code != 400)
|
||||
showSnackbar({
|
||||
message: '获取最近对话失败: ' + e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
updateRecents()
|
||||
|
||||
shared.functions_lazy.current.updateRecentChats = updateRecents
|
||||
|
||||
const id = setInterval(() => updateRecents(), 15 * 1000)
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
}
|
||||
})
|
||||
|
||||
return <mdui-list style={{
|
||||
overflowY: 'auto',
|
||||
paddingRight: '10px',
|
||||
paddingLeft: '10px',
|
||||
paddingTop: '0',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
...props?.style,
|
||||
}} {...props}>
|
||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||
paddingTop: '12px',
|
||||
marginBottom: '13px',
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||
zIndex: '10',
|
||||
}}></mdui-text-field>
|
||||
{
|
||||
recentsList.filter((chat) =>
|
||||
searchText == '' ||
|
||||
chat.getTitle().includes(searchText) ||
|
||||
chat.getId().includes(searchText) ||
|
||||
chat.getContent().includes(searchText)
|
||||
).map((v) =>
|
||||
<RecentsListItem
|
||||
active={isMobileUI() ? false : shared.currentSelectedChatId == v.getId()}
|
||||
openChatFragment={() => openChatFragment(v.getId())}
|
||||
key={v.getId()}
|
||||
recentChat={v} />
|
||||
)
|
||||
}
|
||||
</mdui-list>
|
||||
}
|
||||
36
client/ui/main-page/RecentsListItem.tsx
Normal file
36
client/ui/main-page/RecentsListItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { $ } from "mdui/jq"
|
||||
import Avatar from "../Avatar.tsx"
|
||||
import React from 'react'
|
||||
import getClient from "../../getClient.ts"
|
||||
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
|
||||
|
||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
recentChat: RecentChat
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export default function RecentsListItem({ recentChat, active, ...props }: Args) {
|
||||
const { id, title, avatar_file_hash, content } = recentChat.bean
|
||||
|
||||
const itemRef = React.useRef<HTMLElement>(null)
|
||||
React.useEffect(() => {
|
||||
$(itemRef.current!.shadowRoot).find('.headline').css('margin-top', '3px')
|
||||
})
|
||||
return (
|
||||
<mdui-list-item rounded style={{
|
||||
marginTop: '3px',
|
||||
marginBottom: '3px',
|
||||
}} active={active} ref={itemRef} {...props}>
|
||||
{title}
|
||||
<Avatar src={getClient().getUrlForFileByHash(avatar_file_hash!)} text={title} slot="icon" />
|
||||
<span slot="description"
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "inline-block",
|
||||
whiteSpace: "nowrap", /* 禁止换行 */
|
||||
overflow: "hidden", /* 隐藏溢出内容 */
|
||||
textOverflow: "ellipsis", /* 显示省略号 */
|
||||
}}>{content}</span>
|
||||
</mdui-list-item>
|
||||
)
|
||||
}
|
||||
76
client/ui/main-page/RegisterDialog.tsx
Normal file
76
client/ui/main-page/RegisterDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react'
|
||||
import { Button, Dialog, TextField } from "mdui"
|
||||
import MainSharedContext, { Shared } from '../MainSharedContext'
|
||||
import showSnackbar from '../../utils/showSnackbar'
|
||||
import showCircleProgressDialog from '../showCircleProgressDialog'
|
||||
import getClient from '../../getClient'
|
||||
import performAuth from '../../performAuth'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import useEventListener from '../../utils/useEventListener'
|
||||
|
||||
export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
setShowRegisterDialog: context.setShowRegisterDialog
|
||||
}))
|
||||
|
||||
const dialogRef = React.useRef<Dialog>()
|
||||
useEventListener(dialogRef, 'closed', () => shared.setShowRegisterDialog(false))
|
||||
|
||||
const registerInputUserNameRef = React.useRef<TextField>(null)
|
||||
const registerInputNickNameRef = React.useRef<TextField>(null)
|
||||
const registerInputPasswordRef = React.useRef<TextField>(null)
|
||||
|
||||
return (
|
||||
<mdui-dialog headline="注册" {...props} ref={dialogRef}>
|
||||
|
||||
<mdui-text-field label="用户名 (可选)" ref={registerInputUserNameRef}></mdui-text-field>
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
<mdui-text-field label="昵称" ref={registerInputNickNameRef}></mdui-text-field>
|
||||
<div style={{
|
||||
height: "10px",
|
||||
}}></div>
|
||||
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef}></mdui-text-field>
|
||||
|
||||
<mdui-button slot="action" variant="text" onClick={() => shared.setShowRegisterDialog(false)}>返回</mdui-button>
|
||||
<mdui-button slot="action" variant="text" onClick={async () => {
|
||||
const waitingForRegister = showCircleProgressDialog("注册中...")
|
||||
|
||||
const username = registerInputUserNameRef.current!.value
|
||||
|
||||
let user_id: string
|
||||
try {
|
||||
user_id = await getClient().registerOrThrow({
|
||||
username: username,
|
||||
nickname: registerInputNickNameRef.current!.value,
|
||||
password: registerInputPasswordRef.current!.value,
|
||||
})
|
||||
} catch (e) {
|
||||
user_id = ''
|
||||
if (e instanceof Error) {
|
||||
waitingForRegister.open = false
|
||||
showSnackbar({ message: '注册失败: ' + e.message })
|
||||
return
|
||||
}
|
||||
}
|
||||
waitingForRegister.open = false
|
||||
|
||||
const waitingForLogin = showCircleProgressDialog("登录中...")
|
||||
|
||||
try {
|
||||
await performAuth({
|
||||
account: username == '' ? username : user_id,
|
||||
password: registerInputPasswordRef.current!.value,
|
||||
})
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
showSnackbar({ message: '登录失败: ' + e.message })
|
||||
}
|
||||
}
|
||||
waitingForLogin.open = false
|
||||
}}>注册</mdui-button>
|
||||
</mdui-dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Avatar from "../Avatar.jsx"
|
||||
|
||||
export default function ContactsListItem({ nickName, avatar }) {
|
||||
return (
|
||||
<mdui-list-item rounded style={{
|
||||
marginTop: '3px',
|
||||
marginBottom: '3px',
|
||||
width: '100%',
|
||||
}}>
|
||||
<span style={{
|
||||
width: "100%",
|
||||
}}>{nickName}</span>
|
||||
<Avatar src={avatar} text="title" slot="icon" />
|
||||
</mdui-list-item>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import Avatar from "../Avatar.jsx"
|
||||
|
||||
export default function RecentsListItem({ nickName, avatar, content }) {
|
||||
return (
|
||||
<mdui-list-item rounded style={{
|
||||
marginTop: '3px',
|
||||
marginBottom: '3px',
|
||||
}}>
|
||||
{nickName}
|
||||
<Avatar src={avatar} text={nickName} slot="icon" />
|
||||
<span slot="description"
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "inline-block",
|
||||
whiteSpace: "nowrap", /* 禁止换行 */
|
||||
overflow: "hidden", /* 隐藏溢出内容 */
|
||||
textOverflow: "ellipsis", /* 显示省略号 */
|
||||
}}>{content}</span>
|
||||
</mdui-list-item>
|
||||
)
|
||||
}
|
||||
144
client/ui/routers/ChatInfoDialog.tsx
Normal file
144
client/ui/routers/ChatInfoDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import { dialog, Dialog } from "mdui"
|
||||
import Avatar from "../Avatar.tsx"
|
||||
import { CallbackError, Chat } from 'lingchair-client-protocol'
|
||||
import { data, useLocation, useNavigate, useSearchParams } from 'react-router'
|
||||
import useAsyncEffect from '../../utils/useAsyncEffect.ts'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
|
||||
import getClient from '../../getClient.ts'
|
||||
import useEventListener from '../../utils/useEventListener.ts'
|
||||
import showSnackbar from '../../utils/showSnackbar.ts'
|
||||
|
||||
export default function ChatInfoDialog({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||
myProfileCache: context.myProfileCache,
|
||||
favouriteChats: context.favouriteChats,
|
||||
}))
|
||||
|
||||
const [chat, setChat] = React.useState<Chat>()
|
||||
const [userId, setUserId] = React.useState<string>()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
let currentLocation = useLocation()
|
||||
const navigate = useNavigate()
|
||||
function back() {
|
||||
navigate(-1)
|
||||
}
|
||||
const dialogRef = React.useRef<Dialog>()
|
||||
useEventListener(dialogRef, 'overlay-click', () => back())
|
||||
const id = searchParams.get('id')
|
||||
|
||||
const [favourited, setIsFavourited] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
setIsFavourited(shared.favouriteChats.map((v) => v.getId()).indexOf(chat?.getId() || '') != -1)
|
||||
}, [chat, shared])
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log(currentLocation)
|
||||
}, [currentLocation])
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
console.log(id, currentLocation.pathname)
|
||||
try {
|
||||
if (!currentLocation.pathname.startsWith('/info/')) {
|
||||
dialogRef.current!.open = false
|
||||
return
|
||||
}
|
||||
|
||||
if (id == null) {
|
||||
dialogRef.current!.open = false
|
||||
return back()
|
||||
}
|
||||
|
||||
if (currentLocation.pathname.startsWith('/info/user')) {
|
||||
setChat(await Chat.getOrCreatePrivateChatOrThrow(getClient(), id))
|
||||
setUserId(id)
|
||||
} else
|
||||
setChat(await Chat.getByIdOrThrow(getClient(), id))
|
||||
dialogRef.current!.open = true
|
||||
} catch (e) {
|
||||
if (e instanceof CallbackError)
|
||||
showSnackbar({
|
||||
message: '打开资料卡失败: ' + e.message
|
||||
})
|
||||
console.log(e)
|
||||
back()
|
||||
}
|
||||
}, [id, currentLocation])
|
||||
|
||||
if (!currentLocation.pathname.startsWith('/info/'))
|
||||
return null
|
||||
|
||||
const avatarUrl = getClient().getUrlForFileByHash(chat?.getAvatarFileHash())!
|
||||
|
||||
return (
|
||||
<mdui-dialog ref={dialogRef}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Avatar src={avatarUrl} text={chat?.getTitle()} style={{
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
}} onClick={() => avatarUrl && openImageViewer(avatarUrl)} />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
marginLeft: '15px',
|
||||
marginRight: '15px',
|
||||
fontSize: '16.5px',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '16.5px'
|
||||
}}>{chat?.getTitle()}</span>
|
||||
<span style={{
|
||||
fontSize: '10.5px',
|
||||
marginTop: '3px',
|
||||
color: 'rgb(var(--mdui-color-secondary))',
|
||||
}}>({chat?.getType()}) ID: {chat?.getType() == 'private' ? userId : chat?.getId()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<mdui-divider style={{
|
||||
marginTop: "10px",
|
||||
}}></mdui-divider>
|
||||
|
||||
<mdui-list>
|
||||
<mdui-list-item icon={favourited ? "favorite_border" : "favorite"} rounded onClick={() => dialog({
|
||||
headline: favourited ? "取消收藏对话" : "收藏对话",
|
||||
description: favourited ? "确定从收藏对话列表中移除吗? (虽然这不会导致聊天记录丢失)" : "确定要添加到收藏对话列表吗?",
|
||||
actions: [
|
||||
{
|
||||
text: "取消",
|
||||
onClick: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "确定",
|
||||
onClick: () => {
|
||||
; (async () => {
|
||||
const re = await Client.invoke(favourited ? "User.removeContacts" : "User.addContacts", {
|
||||
token: data.access_token,
|
||||
targets: [
|
||||
chat!.id
|
||||
],
|
||||
})
|
||||
if (re.code != 200)
|
||||
checkApiSuccessOrSncakbar(re, favourited ? "取消收藏失败" : "收藏失败")
|
||||
EventBus.emit('ContactsList.updateContacts')
|
||||
})()
|
||||
return true
|
||||
},
|
||||
}
|
||||
],
|
||||
})}>{favourited ? '取消收藏' : '收藏对话'}</mdui-list-item>
|
||||
<mdui-list-item icon="chat" rounded onClick={() => {
|
||||
chatInfoDialogRef.current!.open = false
|
||||
openChatFragment(chat!.id)
|
||||
}}>打开对话</mdui-list-item>
|
||||
</mdui-list>
|
||||
</mdui-dialog>
|
||||
)
|
||||
}
|
||||
|
||||
23
client/ui/showCircleProgressDialog.ts
Normal file
23
client/ui/showCircleProgressDialog.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { $, dialog } from 'mdui'
|
||||
|
||||
export default function showCircleProgressDialog(text: string) {
|
||||
const d = dialog({
|
||||
body: `
|
||||
<div style="display: flex; align-items: center;">
|
||||
<mdui-circular-progress style="margin-left: 3px"></mdui-circular-progress>
|
||||
<span style="margin-left: 20px;"></span>
|
||||
</div>
|
||||
`,
|
||||
closeOnEsc: false,
|
||||
closeOnOverlayClick: false,
|
||||
})
|
||||
$(d).addClass('waiting-dialog').find('span').text(text)
|
||||
$(d.shadowRoot).append(`
|
||||
<style>
|
||||
.body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
`)
|
||||
return d
|
||||
}
|
||||
5
client/utils/isMobileUI.ts
Normal file
5
client/utils/isMobileUI.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function isMobileUI() {
|
||||
const mobile = new URL(location.href).searchParams.get('mobile')
|
||||
if (mobile) return mobile == 'true'
|
||||
return /Mobi|Android|iPhone/i.test(navigator.userAgent)
|
||||
}
|
||||
17
client/utils/openImageViewer.ts
Normal file
17
client/utils/openImageViewer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { $ } from 'mdui/jq'
|
||||
|
||||
export default function openImageViewer(src: string) {
|
||||
$('#image-viewer-dialog-inner').empty()
|
||||
|
||||
const e = new Image()
|
||||
e.onload = () => ($('#image-viewer-dialog-inner').get(0) as any).scaleTo(0.1, {
|
||||
// Transform origin. Can be a number, or string percent, eg "50%"
|
||||
originX: '50%',
|
||||
originY: '50%',
|
||||
// Should the transform origin be relative to the container, or content?
|
||||
relativeTo: 'container',
|
||||
})
|
||||
e.src = src
|
||||
$('#image-viewer-dialog-inner').append(e)
|
||||
$('#image-viewer-dialog').attr('open', 'true')
|
||||
}
|
||||
34
client/utils/randomUUID.ts
Normal file
34
client/utils/randomUUID.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// https://www.xiabingbao.com/post/crypto/js-crypto-randomuuid-qxcuqj.html
|
||||
// 在此表示感謝
|
||||
|
||||
export default function randomUUID() {
|
||||
// crypto - 只支持在安全的上下文使用
|
||||
if (typeof crypto === 'object') {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
||||
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||
const callback = (c: string) => {
|
||||
const num = Number(c)
|
||||
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16)
|
||||
};
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback)
|
||||
}
|
||||
}
|
||||
// 隨機數 - fallback
|
||||
let timestamp = new Date().getTime()
|
||||
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
let random = Math.random() * 16
|
||||
if (timestamp > 0) {
|
||||
random = (timestamp + random) % 16 | 0
|
||||
timestamp = Math.floor(timestamp / 16)
|
||||
} else {
|
||||
random = (perforNow + random) % 16 | 0
|
||||
perforNow = Math.floor(perforNow / 16)
|
||||
}
|
||||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { snackbar as mduiSnackbar, Snackbar } from "mdui"
|
||||
import ApiCallbackMessage from "../api/ApiCallbackMessage.ts"
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
@@ -83,16 +82,8 @@ interface SnackbarOptions extends Options {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function checkApiSuccessOrSncakbar(re: ApiCallbackMessage, msg_ahead: string, opinions_override: Options = {}): Snackbar | null {
|
||||
return re.code != 200 ? snackbar(
|
||||
Object.assign({
|
||||
message: `${msg_ahead}: ${re.msg} [${re.code}]`,
|
||||
placement: "top",
|
||||
} as SnackbarOptions, opinions_override)
|
||||
) : null
|
||||
}
|
||||
|
||||
export function snackbar(opinions: SnackbarOptions) {
|
||||
opinions.autoCloseDelay == null && (opinions.autoCloseDelay = 2500)
|
||||
export default function showSnackbar(opinions: SnackbarOptions) {
|
||||
opinions.autoCloseDelay == null && (opinions.autoCloseDelay = 3500)
|
||||
opinions.placement == null && (opinions.placement = 'top')
|
||||
return mduiSnackbar(opinions)
|
||||
}
|
||||
1
client/utils/sleep.ts
Normal file
1
client/utils/sleep.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default (t: number) => new Promise((res) => setTimeout(res, t))
|
||||
8
client/utils/useAsyncEffect.ts
Normal file
8
client/utils/useAsyncEffect.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from "react"
|
||||
|
||||
export default function useAsyncEffect(func: Function, deps?: React.DependencyList) {
|
||||
React.useEffect(() => {
|
||||
func()
|
||||
// 警告: 不添加 deps 有可能導致無限執行
|
||||
}, deps || [])
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export default function useEventListener<T extends HTMLElement | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
|
||||
export default function useEventListener<T extends HTMLElement | undefined | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
|
||||
React.useEffect(() => {
|
||||
ref.current!.addEventListener(eventName, callback)
|
||||
return () => ref.current!.removeEventListener(eventName, callback)
|
||||
return () => ref.current?.removeEventListener(eventName, callback)
|
||||
}, [ref, eventName, callback])
|
||||
}
|
||||
@@ -1,14 +1,54 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import deno from '@deno/vite-plugin'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
import config from '../server/config.ts'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
import { execSync } from 'child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
const gitHash = execSync('git rev-parse --short HEAD')
|
||||
.toString()
|
||||
.trim()
|
||||
const gitFullHash = execSync('git rev-parse HEAD')
|
||||
.toString()
|
||||
.trim()
|
||||
const gitBranch = execSync('git rev-parse --abbrev-ref HEAD')
|
||||
.toString()
|
||||
.trim()
|
||||
const versionEnv = {
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(JSON.parse(await fs.readFile('package.json', 'utf-8')).version),
|
||||
__GIT_HASH__: JSON.stringify(gitHash),
|
||||
__GIT_HASH_FULL__: JSON.stringify(gitFullHash),
|
||||
__GIT_BRANCH__: JSON.stringify(gitBranch),
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toLocaleString('zh-CN')),
|
||||
}
|
||||
}
|
||||
|
||||
function gitHashPlugin() {
|
||||
return {
|
||||
name: 'git-hash-plugin',
|
||||
config() {
|
||||
return versionEnv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [deno(), react()],
|
||||
plugins: [
|
||||
react(),
|
||||
nodePolyfills({
|
||||
include: ['crypto', 'stream', 'vm'],
|
||||
globals: {
|
||||
Buffer: true,
|
||||
global: true,
|
||||
process: true,
|
||||
},
|
||||
}),
|
||||
gitHashPlugin(),
|
||||
],
|
||||
build: {
|
||||
sourcemap: "inline",
|
||||
sourcemap: true,
|
||||
outDir: "." + config.data_path + '/page_compiled',
|
||||
},
|
||||
})
|
||||
|
||||
13
deno.jsonc
13
deno.jsonc
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"tasks": {
|
||||
"server": "deno task build && deno run --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
|
||||
"debug": "deno task build && deno run --watch --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
|
||||
"build": "cd ./client && deno task build"
|
||||
},
|
||||
"imports": {
|
||||
"chalk": "npm:chalk@5.4.1",
|
||||
"file-type": "npm:file-type@21.0.0",
|
||||
"express": "npm:express@5.1.0",
|
||||
"socket.io": "npm:socket.io@4.8.1"
|
||||
}
|
||||
}
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: '3'
|
||||
services:
|
||||
lingchair:
|
||||
container_name: lingchair
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "3601:3601"
|
||||
restart: always
|
||||
volumes:
|
||||
- ./thewhitesilk_config.json:/app/thewhitesilk_config.json
|
||||
- ./thewhitesilk_data:/app/thewhitesilk_data
|
||||
network_mode: bridge
|
||||
8
docker-update.sh
Normal file
8
docker-update.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
docker stop lingchair
|
||||
docker rm lingchair
|
||||
docker rmi lingchair
|
||||
echo "remove success"
|
||||
git pull
|
||||
echo "pull success"
|
||||
docker compose up -d
|
||||
echo "update success"
|
||||
@@ -8,8 +8,9 @@ type ApiCallbackMessage = {
|
||||
* 404: Not Found
|
||||
* 500: 伺服器端錯誤
|
||||
* 501: 伺服器端不支持請求的功能
|
||||
* -1: 请求错误
|
||||
*/
|
||||
code: 200 | 400 | 401 | 403 | 404 | 500 | 501,
|
||||
code: 200 | 400 | 401 | 403 | 404 | 500 | 501 | -1,
|
||||
data?: { [key: string]: unknown },
|
||||
}
|
||||
export default ApiCallbackMessage
|
||||
66
internal-shared/ApiDeclare.ts
Normal file
66
internal-shared/ApiDeclare.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export type CallMethod =
|
||||
// 用户验证
|
||||
"User.auth" |
|
||||
"User.register" |
|
||||
"User.login" |
|
||||
"User.refreshAccessToken" |
|
||||
|
||||
// 账号
|
||||
"User.setAvatar" |
|
||||
"User.updateProfile" |
|
||||
"User.getMyInfo" |
|
||||
"User.resetPassword" |
|
||||
|
||||
// 用户资料
|
||||
"User.getInfo" |
|
||||
|
||||
// 收藏对话列表
|
||||
"User.getMyContacts" |
|
||||
"User.addContacts" |
|
||||
"User.removeContacts" |
|
||||
|
||||
// 最近对话列表
|
||||
"User.getMyRecentChats" |
|
||||
|
||||
// 全部对话列表
|
||||
"User.getMyAllChats" |
|
||||
|
||||
// 对话信息
|
||||
"Chat.getInfo" |
|
||||
"Chat.getAnotherUserIdFromPrivate" |
|
||||
"Chat.getMembers" |
|
||||
|
||||
// 对话设置
|
||||
"Chat.updateSettings" |
|
||||
"Chat.setAvatar" |
|
||||
|
||||
// 对话创建
|
||||
"Chat.createGroup" |
|
||||
"Chat.getIdForPrivate" |
|
||||
|
||||
// 入群请求
|
||||
"Chat.processJoinRequest" |
|
||||
"Chat.sendJoinRequest" |
|
||||
"Chat.getJoinRequests" |
|
||||
|
||||
// 对话成员
|
||||
"Chat.removeMembers" |
|
||||
"Chat.quit" |
|
||||
|
||||
// 对话消息
|
||||
"Chat.sendMessage" |
|
||||
"Chat.getMessageHistory"
|
||||
|
||||
// (废弃) 文件上传
|
||||
// "Chat.uploadFile"
|
||||
|
||||
export type ClientEvent =
|
||||
// 对话收消息
|
||||
"Client.onMessage"
|
||||
|
||||
export const CallableMethodBeforeAuth = [
|
||||
"User.auth",
|
||||
"User.register",
|
||||
"User.login",
|
||||
"User.refreshAccessToken",
|
||||
]
|
||||
7
internal-shared/main.ts
Normal file
7
internal-shared/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './ApiDeclare.ts'
|
||||
|
||||
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
||||
export type { ApiCallbackMessage }
|
||||
|
||||
import randomUUID from './randomUUID.ts'
|
||||
export { randomUUID }
|
||||
5
internal-shared/package.json
Normal file
5
internal-shared/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "lingchair-internal-shared",
|
||||
"type": "module",
|
||||
"main": "./main.ts"
|
||||
}
|
||||
34
internal-shared/randomUUID.ts
Normal file
34
internal-shared/randomUUID.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// https://www.xiabingbao.com/post/crypto/js-crypto-randomuuid-qxcuqj.html
|
||||
|
||||
export default function randomUUID() {
|
||||
// crypto - 只支持在安全的上下文使用
|
||||
if (typeof crypto === 'object') {
|
||||
// crypto-browserify 没有这个方法
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
||||
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||
const callback = (c: string) => {
|
||||
const num = Number(c)
|
||||
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16)
|
||||
};
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback)
|
||||
}
|
||||
}
|
||||
// 随机数
|
||||
let timestamp = new Date().getTime()
|
||||
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
let random = Math.random() * 16
|
||||
if (timestamp > 0) {
|
||||
random = (timestamp + random) % 16 | 0
|
||||
timestamp = Math.floor(timestamp / 16)
|
||||
} else {
|
||||
random = (perforNow + random) % 16 | 0
|
||||
perforNow = Math.floor(perforNow / 16)
|
||||
}
|
||||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
7
license
Normal file
7
license
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 月有陰晴圓缺(CrescentLeaf/MoonLeeeaf)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
21
mdui_patched/LICENSE
Normal file
21
mdui_patched/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016-present zdhxiong@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
49
mdui_patched/README.md
Normal file
49
mdui_patched/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# mdui
|
||||
|
||||
[](https://badge.fury.io/gh/zdhxiong%2Fmdui)
|
||||
[](https://www.npmjs.com/package/mdui)
|
||||
[](https://cdnjs.com/libraries/mdui)
|
||||
|
||||
[官网](https://www.mdui.org) | [文档](https://www.mdui.org/docs/2/)
|
||||
|
||||
使用 Web Components 实现,遵循 Material You 设计规范的 Web 前端组件库。
|
||||
|
||||
* **Web Components**:mdui 组件全部使用 Web Components 开发,使用组件就像使用 `<div>` 标签一样简单。
|
||||
* **Material You**:遵循最新的 Material Design 3(Material You)设计规范,使你的产品美观、易用。
|
||||
* **动态配色**:支持根据给定颜色值,或给定一张图片,mdui 能自动计算出颜色值,生成整套配色方案,并在所有 mdui 组件中生效。
|
||||
* **暗色模式**:所有组件都支持暗色模式、及支持根据操作系统设置自动切换亮色模式和暗色模式。
|
||||
* **轻量级**:gzip 后的 CSS + JavaScript 仅 85KB,使用按需导入可进一步减小体积,使加载更迅速。
|
||||
* **IDE 支持**:在 VSCode 和 WebStorm 中能获得完美的代码提示。且提供了 VSCode 扩展和 WebStorm 插件,使开发更便捷。
|
||||
* **兼容所有框架**:mdui 能兼容 Vue、React、Angular 等框架,只要在浏览器上运行的应用,都能使用 mdui。
|
||||
* **TypeScript 支持**:mdui 完全使用 TypeScript 开发,拥有完美的类型提示。
|
||||
* **无依赖**:不需要依赖任何第三方库,节约网络流量,使加载更迅速。
|
||||
* **组件丰富**:mdui 包含 30 多个组件,及十余个工具函数,常用组件都有。
|
||||
* **Material Icons 图标库**:提供了超过 1 万个图标组件,可按需导入所需图标。
|
||||
* **低学习成本**:只需懂一点 HTML、CSS、JavaScript 的基础知识,就能使用 mdui。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install mdui --save
|
||||
```
|
||||
|
||||
### 导入 CSS 及 JS 文件
|
||||
|
||||
```js
|
||||
import 'mdui/mdui.css';
|
||||
import 'mdui';
|
||||
```
|
||||
|
||||
### 使用组件
|
||||
|
||||
```html
|
||||
<mdui-button>Button</mdui-button>
|
||||
```
|
||||
|
||||
## 赞助
|
||||
|
||||
赞助以帮助 mdui 持续更新
|
||||
|
||||

|
||||

|
||||
[](https://www.paypal.me/zdhxiong/5)
|
||||
1
mdui_patched/components/avatar.d.ts
vendored
Normal file
1
mdui_patched/components/avatar.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from './avatar/index.js';
|
||||
1
mdui_patched/components/avatar.js
Normal file
1
mdui_patched/components/avatar.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './avatar/index.js';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user