Compare commits
281 Commits
af694f6f6c
...
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 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,6 +2,6 @@
|
|||||||
thewhitesilk_config.json
|
thewhitesilk_config.json
|
||||||
# **默认**数据目录
|
# **默认**数据目录
|
||||||
thewhitesilk_data/
|
thewhitesilk_data/
|
||||||
|
# Node.js
|
||||||
deno.lock
|
package-lock.json
|
||||||
node_modules/
|
node_modules/
|
||||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -5,10 +5,10 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"command": "deno task debug",
|
"command": "npm run debug",
|
||||||
"name": "Run debug",
|
"name": "Debug",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal"
|
||||||
},
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"deno.enable": true,
|
|
||||||
"deno.disablePaths": [
|
|
||||||
"./thewhitesilk_data"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export default class User {
|
export default class UserBean {
|
||||||
declare id: string
|
declare id: string
|
||||||
declare username?: string
|
declare username?: string
|
||||||
declare nickname: string
|
declare nickname: string
|
||||||
declare avatar?: 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,49 +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: {
|
|
||||||
split_sizes: number[]
|
|
||||||
apply(): void
|
|
||||||
access_token?: string
|
|
||||||
device_id: 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,12 +0,0 @@
|
|||||||
export default class EventBus {
|
|
||||||
static events: { [key: string]: () => void } = {}
|
|
||||||
static on(eventName: string, func: () => void) {
|
|
||||||
this.events[eventName] = func
|
|
||||||
}
|
|
||||||
static off(eventName: string) {
|
|
||||||
delete this.events[eventName]
|
|
||||||
}
|
|
||||||
static emit(eventName: string) {
|
|
||||||
this.events[eventName]()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export type CallMethod =
|
|
||||||
"User.auth" |
|
|
||||||
"User.register" |
|
|
||||||
"User.login" |
|
|
||||||
|
|
||||||
"User.setAvatar" |
|
|
||||||
"User.updateProfile" |
|
|
||||||
"User.getMyInfo" |
|
|
||||||
|
|
||||||
"User.getInfo" |
|
|
||||||
|
|
||||||
"User.getMyContacts" |
|
|
||||||
"User.addContact" |
|
|
||||||
"User.removeContacts" |
|
|
||||||
|
|
||||||
"Chat.getInfo" |
|
|
||||||
"Chat.sendMessage" |
|
|
||||||
"Chat.getMessageHistory" |
|
|
||||||
|
|
||||||
"Chat.uploadFile"
|
|
||||||
|
|
||||||
export const CallableMethodBeforeAuth = [
|
|
||||||
"User.auth",
|
|
||||||
"User.register",
|
|
||||||
"User.login",
|
|
||||||
]
|
|
||||||
|
|
||||||
export type ClientEvent =
|
|
||||||
"Client.onMessage"
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { io, Socket } from 'socket.io-client'
|
|
||||||
import { CallMethod, ClientEvent, CallableMethodBeforeAuth } from './ApiDeclare.ts'
|
|
||||||
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
|
||||||
import User from "./client_data/User.ts"
|
|
||||||
import data from "../Data.ts"
|
|
||||||
import { checkApiSuccessOrSncakbar } from "../ui/snackbar.ts"
|
|
||||||
import randomUUID from "../randomUUID.ts"
|
|
||||||
|
|
||||||
class Client {
|
|
||||||
static sessionId = randomUUID()
|
|
||||||
static myUserProfile?: User
|
|
||||||
static socket?: Socket
|
|
||||||
static events: { [key: string]: ((data: unknown) => void)[] } = {}
|
|
||||||
static connected = false
|
|
||||||
static connect() {
|
|
||||||
if (data.device_id == null)
|
|
||||||
data.device_id = randomUUID()
|
|
||||||
this.socket?.disconnect()
|
|
||||||
this.socket && delete this.socket
|
|
||||||
this.socket = io({
|
|
||||||
transports: ['websocket'],
|
|
||||||
auth: {
|
|
||||||
device_id: data.device_id,
|
|
||||||
session_id: this.sessionId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.socket!.on("connect", async () => {
|
|
||||||
const re = await this.invoke("User.auth", {
|
|
||||||
access_token: data.access_token
|
|
||||||
}, 1000)
|
|
||||||
if (re.code != 200)
|
|
||||||
checkApiSuccessOrSncakbar(re, "重連失敗")
|
|
||||||
this.connected = true
|
|
||||||
})
|
|
||||||
this.socket!.on("disconnect", () => {
|
|
||||||
this.connected = false
|
|
||||||
})
|
|
||||||
this.socket!.on("The_White_Silk", (name: string, data: unknown, callback: (ret: unknown) => void) => {
|
|
||||||
try {
|
|
||||||
if (name == null || data == null) return
|
|
||||||
this.events[name]?.forEach((v) => v(data))
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
static invoke(method: CallMethod, args: unknown = {}, timeout: number = 5000): Promise<ApiCallbackMessage> {
|
|
||||||
if (this.socket == null || (!this.connected && !CallableMethodBeforeAuth.includes(method))) {
|
|
||||||
return new Promise((reslove) => {
|
|
||||||
setTimeout(async () => reslove(await this.invoke(method, args, timeout)), 500)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.socket!.timeout(timeout).emit("The_White_Silk", method, args, (err: Error, res: ApiCallbackMessage) => {
|
|
||||||
if (err) return resolve({
|
|
||||||
code: -1,
|
|
||||||
msg: err.message.indexOf("timed out") != -1 ? "請求超時" : err.message,
|
|
||||||
})
|
|
||||||
resolve(res)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
static async auth(token: string, timeout: number = 5000) {
|
|
||||||
const re = await this.invoke("User.auth", {
|
|
||||||
access_token: token
|
|
||||||
}, timeout)
|
|
||||||
if (re.code == 200) {
|
|
||||||
await this.updateCachedProfile()
|
|
||||||
document.cookie = 'token=' + token
|
|
||||||
document.cookie = 'device_id=' + data.device_id
|
|
||||||
}
|
|
||||||
return re
|
|
||||||
}
|
|
||||||
static async updateCachedProfile() {
|
|
||||||
this.myUserProfile = (await Client.invoke("User.getMyInfo", {
|
|
||||||
token: data.access_token
|
|
||||||
})).data as unknown as User
|
|
||||||
}
|
|
||||||
static on(eventName: ClientEvent, func: (data: unknown) => void) {
|
|
||||||
if (this.events[eventName] == null)
|
|
||||||
this.events[eventName] = []
|
|
||||||
if (this.events[eventName].indexOf(func) == -1)
|
|
||||||
this.events[eventName].push(func)
|
|
||||||
}
|
|
||||||
static off(eventName: ClientEvent, func: (data: unknown) => void) {
|
|
||||||
if (this.events[eventName] == null)
|
|
||||||
this.events[eventName] = []
|
|
||||||
const index = this.events[eventName].indexOf(func)
|
|
||||||
if (index != -1)
|
|
||||||
this.events[eventName].splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Client
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import data from "../Data.ts"
|
|
||||||
import Client from "./Client.ts"
|
|
||||||
import User from "./client_data/User.ts"
|
|
||||||
|
|
||||||
export default class DataCaches {
|
|
||||||
static userProfiles: { [key: string]: User} = {}
|
|
||||||
static async getUserProfile(userId: string): Promise<User> {
|
|
||||||
if (this.userProfiles[userId]) return this.userProfiles[userId]
|
|
||||||
const re = await Client.invoke("User.getInfo", {
|
|
||||||
token: data.access_token,
|
|
||||||
target: userId
|
|
||||||
})
|
|
||||||
if (re.code != 200) return {
|
|
||||||
id: '',
|
|
||||||
nickname: "",
|
|
||||||
}
|
|
||||||
return this.userProfiles[userId] = (re.data as unknown as User)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export default class Chat {
|
|
||||||
declare type: "paivate" | "group"
|
|
||||||
declare id: string
|
|
||||||
declare title: string
|
|
||||||
declare avatar_file_hash?: string
|
|
||||||
declare user_a_id?: string
|
|
||||||
declare user_b_id?: string
|
|
||||||
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default class Message {
|
|
||||||
declare id: number
|
|
||||||
declare text: string
|
|
||||||
declare user_id: string
|
|
||||||
declare time: string
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import Chat from "./Chat.ts"
|
|
||||||
|
|
||||||
export default class RecentChat extends Chat {
|
|
||||||
declare content: string
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import process from 'node:process'
|
|
||||||
import child_process from 'node:child_process'
|
|
||||||
import fs from 'node:fs/promises'
|
|
||||||
|
|
||||||
function spawn(exec: string, args: string[]) {
|
|
||||||
child_process.spawnSync(exec, args, {
|
|
||||||
stdio: [process.stdin, process.stdout, process.stderr]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function runBuild() {
|
|
||||||
const args = [
|
|
||||||
"run",
|
|
||||||
"-A",
|
|
||||||
"--node-modules-dir",
|
|
||||||
]
|
|
||||||
let i = 0
|
|
||||||
for (const arg of process.argv) {
|
|
||||||
if (i > 1)
|
|
||||||
args.push(arg)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
spawn('deno', args)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform == 'android') {
|
|
||||||
try {
|
|
||||||
await fs.stat('./node_modules/.deno/rollup@4.50.1/node_modules/rollup/')
|
|
||||||
} catch (e) {
|
|
||||||
spawn('deno', ['install', '--node-modules-dir=auto'])
|
|
||||||
}
|
|
||||||
spawn('sh', ["fix-build-on-android.sh"])
|
|
||||||
}
|
|
||||||
|
|
||||||
runBuild()
|
|
||||||
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,36 +0,0 @@
|
|||||||
{
|
|
||||||
"tasks": {
|
|
||||||
"build": "deno run --allow-run --allow-env --allow-read checkIsAndroidAndBuild.ts npm:vite build",
|
|
||||||
"build-watch": "deno run --allow-run --allow-env --allow-read checkIsAndroidAndBuild.ts 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",
|
|
||||||
"vite-plugin-babel": "npm:vite-plugin-babel@1.3.2",
|
|
||||||
"@babel/preset-env": "npm:@babel/preset-env@7.28.3",
|
|
||||||
"react": "npm:react@18.3.1",
|
|
||||||
"react-dom": "npm:react-dom@18.3.1",
|
|
||||||
"vite": "npm:vite@7.0.6",
|
|
||||||
|
|
||||||
"rollup": "npm:@rollup/wasm-node@4.48.0",
|
|
||||||
|
|
||||||
"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",
|
|
||||||
"marked": "npm:marked@16.3.0",
|
|
||||||
"dompurify": "npm:dompurify@3.2.7",
|
|
||||||
"pinch-zoom-element": "npm:pinch-zoom-element@1.1.1",
|
|
||||||
"react-json-view": "npm:react-json-view@1.21.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
rm -r ./node_modules/.deno/rollup@4.50.1/node_modules/rollup/
|
|
||||||
cp -r ./node_modules/.deno/@rollup+wasm-node@4.48.0/node_modules/@rollup/wasm-node/ node_modules/.deno/rollup@4.50.1/node_modules/rollup/
|
|
||||||
echo Replaced rollup with @rollup/wasm-node successfully
|
|
||||||
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="icon" href="icon.ico" />
|
||||||
<link rel="stylesheet" href="./static/material_icons.css" />
|
<link rel="stylesheet" href="./static/material_icons.css" />
|
||||||
|
|
||||||
<title>TheWhiteSilk</title>
|
<title>LingChair</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="./style.css" />
|
<link rel="stylesheet" href="./style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<mdui-snackbar close-on-outside-click id="public_snackbar"></mdui-snackbar>
|
|
||||||
|
|
||||||
<mdui-dialog close-on-overlay-click id="ErrorDialog">
|
<script type="module" src="./init.ts"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import 'mdui/mdui.css'
|
|
||||||
import 'mdui'
|
|
||||||
import { $ } from "mdui/jq"
|
|
||||||
import { breakpoint, Dialog } from "mdui"
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
|
|
||||||
import './ui/custom-elements/chat-image.ts'
|
|
||||||
import './ui/custom-elements/chat-video.ts'
|
|
||||||
import './ui/custom-elements/chat-file.ts'
|
|
||||||
|
|
||||||
const urlParams = new URL(location.href).searchParams
|
|
||||||
|
|
||||||
// deno-lint-ignore no-window no-window-prefix
|
|
||||||
urlParams.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 App from './ui/App.tsx'
|
|
||||||
import AppMobile from './ui/AppMobile.tsx'
|
|
||||||
import isMobileUI from "./ui/isMobileUI.ts"
|
|
||||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(isMobileUI() ? AppMobile : App, null))
|
|
||||||
|
|
||||||
const onResize = () => setTimeout(() => {
|
|
||||||
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
|
|
||||||
// deno-lint-ignore no-window
|
|
||||||
document.body.style.setProperty('--whitesilk-window-width', window.innerWidth + 'px')
|
|
||||||
// deno-lint-ignore no-window
|
|
||||||
document.body.style.setProperty('--whitesilk-window-height', window.innerHeight + 'px')
|
|
||||||
}, 100)
|
|
||||||
// deno-lint-ignore no-window no-window-prefix
|
|
||||||
window.addEventListener('resize', onResize)
|
|
||||||
onResize()
|
|
||||||
41
client/init.ts
Normal file
41
client/init.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'mdui/mdui.css'
|
||||||
|
import 'mdui'
|
||||||
|
import { breakpoint } from "mdui"
|
||||||
|
|
||||||
|
import './env.d.ts'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
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 performAuth from './performAuth.ts'
|
||||||
|
|
||||||
|
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%")
|
||||||
|
// deno-lint-ignore no-window
|
||||||
|
document.body.style.setProperty('--whitesilk-window-width', window.innerWidth + 'px')
|
||||||
|
// deno-lint-ignore no-window
|
||||||
|
document.body.style.setProperty('--whitesilk-window-height', window.innerHeight + 'px')
|
||||||
|
}
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -67,3 +67,7 @@ html {
|
|||||||
.gutter.gutter-horizontal {
|
.gutter.gutter-horizontal {
|
||||||
cursor: col-resize;
|
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,189 +0,0 @@
|
|||||||
import Client from "../api/Client.ts"
|
|
||||||
import data from "../Data.ts"
|
|
||||||
import ChatFragment from "./chat/ChatFragment.tsx"
|
|
||||||
import useEventListener from './useEventListener.ts'
|
|
||||||
import User from "../api/client_data/User.ts"
|
|
||||||
import RecentChat from "../api/client_data/RecentChat.ts"
|
|
||||||
import Avatar from "./Avatar.tsx"
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { 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"
|
|
||||||
import LoginDialog from "./dialog/LoginDialog.tsx"
|
|
||||||
import MyProfileDialog from "./dialog/MyProfileDialog.tsx"
|
|
||||||
import ContactsList from "./main/ContactsList.tsx"
|
|
||||||
import RecentsList from "./main/RecentsList.tsx"
|
|
||||||
import useAsyncEffect from "./useAsyncEffect.ts"
|
|
||||||
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
|
|
||||||
import Chat from "../api/client_data/Chat.ts"
|
|
||||||
import AddContactDialog from './dialog/AddContactDialog.tsx'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace React {
|
|
||||||
namespace JSX {
|
|
||||||
interface IntrinsicAttributes {
|
|
||||||
id?: string
|
|
||||||
slot?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [recentsList, setRecentsList] = React.useState([] as RecentChat[])
|
|
||||||
|
|
||||||
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
|
|
||||||
|
|
||||||
const navigationRailRef = React.useRef<NavigationRail>(null)
|
|
||||||
useEventListener(navigationRailRef, 'change', (event) => {
|
|
||||||
setNavigationItemSelected((event.target as HTMLElement as NavigationRail).value as string)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loginDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const loginInputAccountRef = React.useRef<TextField>(null)
|
|
||||||
const loginInputPasswordRef = React.useRef<TextField>(null)
|
|
||||||
|
|
||||||
const registerDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const registerInputUserNameRef = React.useRef<TextField>(null)
|
|
||||||
const registerInputNickNameRef = React.useRef<TextField>(null)
|
|
||||||
const registerInputPasswordRef = React.useRef<TextField>(null)
|
|
||||||
|
|
||||||
const myProfileDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const openMyProfileDialogButtonRef = React.useRef<HTMLElement>(null)
|
|
||||||
useEventListener(openMyProfileDialogButtonRef, 'click', (_event) => {
|
|
||||||
myProfileDialogRef.current!.open = true
|
|
||||||
})
|
|
||||||
|
|
||||||
const addContactDialogRef = React.useRef<Dialog>(null)
|
|
||||||
|
|
||||||
const chatInfoDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
|
|
||||||
|
|
||||||
const [myUserProfileCache, setMyUserProfileCache] = React.useState(null as unknown as User)
|
|
||||||
|
|
||||||
const [isShowChatFragment, setIsShowChatFragment] = React.useState(false)
|
|
||||||
|
|
||||||
const [currentChatId, setCurrentChatId] = React.useState('')
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
const split = Split(['#SideBar', '#ChatFragment'], {
|
|
||||||
sizes: data.split_sizes ? data.split_sizes : [25, 75],
|
|
||||||
minSize: [200, 400],
|
|
||||||
gutterSize: 2,
|
|
||||||
onDragEnd: function () {
|
|
||||||
data.split_sizes = split.getSizes()
|
|
||||||
data.apply()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Client.connect()
|
|
||||||
const re = await Client.auth(data.access_token || "")
|
|
||||||
if (re.code == 401)
|
|
||||||
loginDialogRef.current!.open = true
|
|
||||||
else if (re.code != 200) {
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "驗證失敗")) return
|
|
||||||
} else if (re.code == 200) {
|
|
||||||
setMyUserProfileCache(Client.myUserProfile as User)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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} />
|
|
||||||
|
|
||||||
<MyProfileDialog
|
|
||||||
myProfileDialogRef={myProfileDialogRef as any}
|
|
||||||
user={myUserProfileCache} />
|
|
||||||
|
|
||||||
<ChatInfoDialog
|
|
||||||
chatInfoDialogRef={chatInfoDialogRef as any}
|
|
||||||
openChatFragment={(id) => {
|
|
||||||
setCurrentChatId(id)
|
|
||||||
setIsShowChatFragment(true)
|
|
||||||
}}
|
|
||||||
chat={chatInfo} />
|
|
||||||
|
|
||||||
<AddContactDialog
|
|
||||||
addContactDialogRef={addContactDialogRef} />
|
|
||||||
|
|
||||||
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
|
|
||||||
<mdui-button-icon slot="top">
|
|
||||||
<Avatar src={myUserProfileCache?.avatar} text={myUserProfileCache?.nickname} avatarRef={openMyProfileDialogButtonRef} />
|
|
||||||
</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="chat--outlined" active-icon="chat--filled" value="Contacts"></mdui-navigation-rail-item>
|
|
||||||
|
|
||||||
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
|
|
||||||
</mdui-navigation-rail>
|
|
||||||
{
|
|
||||||
// 侧边列表
|
|
||||||
}
|
|
||||||
<div id="SideBar">
|
|
||||||
{
|
|
||||||
// 最近聊天
|
|
||||||
<RecentsList
|
|
||||||
openChatFragment={(id) => {
|
|
||||||
setCurrentChatId(id)
|
|
||||||
setIsShowChatFragment(true)
|
|
||||||
}}
|
|
||||||
display={navigationItemSelected == "Recents"}
|
|
||||||
currentChatId={currentChatId}
|
|
||||||
recentsList={recentsList}
|
|
||||||
setRecentsList={setRecentsList} />
|
|
||||||
}
|
|
||||||
{
|
|
||||||
// 對話列表
|
|
||||||
<ContactsList
|
|
||||||
setChatInfo={setChatInfo}
|
|
||||||
addContactDialogRef={addContactDialogRef as any}
|
|
||||||
chatInfoDialogRef={chatInfoDialogRef as any}
|
|
||||||
display={navigationItemSelected == "Contacts"} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
// 聊天页面
|
|
||||||
}
|
|
||||||
<div id="ChatFragment" style={{
|
|
||||||
display: "flex",
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
{
|
|
||||||
!isShowChatFragment && <div style={{
|
|
||||||
width: '100%',
|
|
||||||
textAlign: 'center',
|
|
||||||
alignSelf: 'center',
|
|
||||||
}}>
|
|
||||||
選擇以開始對話...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
isShowChatFragment && <ChatFragment
|
|
||||||
target={currentChatId}
|
|
||||||
key={currentChatId} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import Client from "../api/Client.ts"
|
|
||||||
import data from "../Data.ts"
|
|
||||||
import ChatFragment from "./chat/ChatFragment.tsx"
|
|
||||||
import useEventListener from './useEventListener.ts'
|
|
||||||
import User from "../api/client_data/User.ts"
|
|
||||||
import RecentChat from "../api/client_data/RecentChat.ts"
|
|
||||||
import Avatar from "./Avatar.tsx"
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import { Dialog, NavigationBar, TextField } from "mdui"
|
|
||||||
import 'mdui/jsx.zh-cn.d.ts'
|
|
||||||
import { checkApiSuccessOrSncakbar } from "./snackbar.ts"
|
|
||||||
|
|
||||||
import RegisterDialog from "./dialog/RegisterDialog.tsx"
|
|
||||||
import LoginDialog from "./dialog/LoginDialog.tsx"
|
|
||||||
import MyProfileDialog from "./dialog/MyProfileDialog.tsx"
|
|
||||||
import ContactsList from "./main/ContactsList.tsx"
|
|
||||||
import RecentsList from "./main/RecentsList.tsx"
|
|
||||||
import useAsyncEffect from "./useAsyncEffect.ts"
|
|
||||||
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
|
|
||||||
import Chat from "../api/client_data/Chat.ts"
|
|
||||||
import AddContactDialog from './dialog/AddContactDialog.tsx'
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace React {
|
|
||||||
namespace JSX {
|
|
||||||
interface IntrinsicAttributes {
|
|
||||||
id?: string
|
|
||||||
slot?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppMobile() {
|
|
||||||
const [recentsList, setRecentsList] = React.useState([] as RecentChat[])
|
|
||||||
|
|
||||||
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
|
|
||||||
|
|
||||||
const navigationBarRef = React.useRef<NavigationBar>(null)
|
|
||||||
useEventListener(navigationBarRef, 'change', (event) => {
|
|
||||||
setNavigationItemSelected((event.target as HTMLElement as NavigationBar).value as string)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loginDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const loginInputAccountRef = React.useRef<TextField>(null)
|
|
||||||
const loginInputPasswordRef = React.useRef<TextField>(null)
|
|
||||||
|
|
||||||
const registerDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const registerInputUserNameRef = React.useRef<TextField>(null)
|
|
||||||
const registerInputNickNameRef = React.useRef<TextField>(null)
|
|
||||||
const registerInputPasswordRef = React.useRef<TextField>(null)
|
|
||||||
|
|
||||||
const myProfileDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const openMyProfileDialogButtonRef = React.useRef<HTMLElement>(null)
|
|
||||||
useEventListener(openMyProfileDialogButtonRef, 'click', (_event) => {
|
|
||||||
myProfileDialogRef.current!.open = true
|
|
||||||
})
|
|
||||||
|
|
||||||
const addContactDialogRef = React.useRef<Dialog>(null)
|
|
||||||
|
|
||||||
const chatInfoDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
|
|
||||||
|
|
||||||
const [myUserProfileCache, setMyUserProfileCache] = React.useState(null as unknown as User)
|
|
||||||
|
|
||||||
const [isShowChatFragment, setIsShowChatFragment] = React.useState(false)
|
|
||||||
|
|
||||||
const [currentChatId, setCurrentChatId] = React.useState('')
|
|
||||||
|
|
||||||
const chatFragmentDialogRef = React.useRef<Dialog>(null)
|
|
||||||
React.useEffect(() => {
|
|
||||||
const shadow = chatFragmentDialogRef.current!.shadowRoot as ShadowRoot
|
|
||||||
const panel = shadow.querySelector(".panel") as HTMLElement
|
|
||||||
panel.style.padding = '0'
|
|
||||||
panel.style.color = 'inherit'
|
|
||||||
panel.style.backgroundColor = 'rgb(var(--mdui-color-background))'
|
|
||||||
panel.style.setProperty('--mdui-color-background', 'inherit')
|
|
||||||
const body = shadow.querySelector(".body") as HTMLElement
|
|
||||||
body.style.height = '100%'
|
|
||||||
body.style.display = 'flex'
|
|
||||||
})
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
Client.connect()
|
|
||||||
const re = await Client.auth(data.access_token || "")
|
|
||||||
if (re.code == 401)
|
|
||||||
loginDialogRef.current!.open = true
|
|
||||||
else if (re.code != 200) {
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "驗證失敗")) return
|
|
||||||
} else if (re.code == 200) {
|
|
||||||
setMyUserProfileCache(Client.myUserProfile as User)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: "flex",
|
|
||||||
position: 'relative',
|
|
||||||
flexDirection: 'column',
|
|
||||||
width: 'var(--whitesilk-window-width)',
|
|
||||||
height: 'var(--whitesilk-window-height)',
|
|
||||||
}}>
|
|
||||||
<mdui-dialog fullscreen open={isShowChatFragment} ref={chatFragmentDialogRef}>
|
|
||||||
{
|
|
||||||
// 聊天页面
|
|
||||||
}
|
|
||||||
<div id="ChatFragment" style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
}}>
|
|
||||||
<ChatFragment
|
|
||||||
showReturnButton={true}
|
|
||||||
onReturnButtonClicked={() => setIsShowChatFragment(false)}
|
|
||||||
key={currentChatId}
|
|
||||||
target={currentChatId} />
|
|
||||||
</div>
|
|
||||||
</mdui-dialog>
|
|
||||||
|
|
||||||
<LoginDialog
|
|
||||||
loginDialogRef={loginDialogRef}
|
|
||||||
loginInputAccountRef={loginInputAccountRef}
|
|
||||||
loginInputPasswordRef={loginInputPasswordRef}
|
|
||||||
registerDialogRef={registerDialogRef} />
|
|
||||||
|
|
||||||
<RegisterDialog
|
|
||||||
registerDialogRef={registerDialogRef}
|
|
||||||
registerInputUserNameRef={registerInputUserNameRef}
|
|
||||||
registerInputNickNameRef={registerInputNickNameRef}
|
|
||||||
registerInputPasswordRef={registerInputPasswordRef}
|
|
||||||
loginInputAccountRef={loginInputAccountRef}
|
|
||||||
loginInputPasswordRef={loginInputPasswordRef} />
|
|
||||||
|
|
||||||
<MyProfileDialog
|
|
||||||
myProfileDialogRef={myProfileDialogRef as any}
|
|
||||||
user={myUserProfileCache} />
|
|
||||||
|
|
||||||
<ChatInfoDialog
|
|
||||||
chatInfoDialogRef={chatInfoDialogRef as any}
|
|
||||||
openChatFragment={(id) => {
|
|
||||||
setCurrentChatId(id)
|
|
||||||
setIsShowChatFragment(true)
|
|
||||||
}}
|
|
||||||
chat={chatInfo} />
|
|
||||||
|
|
||||||
<AddContactDialog
|
|
||||||
addContactDialogRef={addContactDialogRef} />
|
|
||||||
|
|
||||||
<mdui-top-app-bar style={{
|
|
||||||
position: 'sticky',
|
|
||||||
marginTop: '3px',
|
|
||||||
marginRight: '6px',
|
|
||||||
marginLeft: '15px',
|
|
||||||
top: '0px',
|
|
||||||
}}>
|
|
||||||
<mdui-top-app-bar-title>{
|
|
||||||
({
|
|
||||||
Recents: "最近對話",
|
|
||||||
Contacts: "所有對話"
|
|
||||||
})[navigationItemSelected]
|
|
||||||
}</mdui-top-app-bar-title>
|
|
||||||
<div style={{
|
|
||||||
flexGrow: 1,
|
|
||||||
}}></div>
|
|
||||||
<mdui-button-icon icon="settings"></mdui-button-icon>
|
|
||||||
<mdui-button-icon>
|
|
||||||
<Avatar src={myUserProfileCache?.avatar} text={myUserProfileCache?.nickname} avatarRef={openMyProfileDialogButtonRef} />
|
|
||||||
</mdui-button-icon>
|
|
||||||
</mdui-top-app-bar>
|
|
||||||
{
|
|
||||||
// 侧边列表
|
|
||||||
}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
height: 'calc(100% - 80px - 67px)',
|
|
||||||
width: '100%',
|
|
||||||
}} id="SideBar">
|
|
||||||
{
|
|
||||||
// 最近聊天
|
|
||||||
<RecentsList
|
|
||||||
openChatFragment={(id) => {
|
|
||||||
setCurrentChatId(id)
|
|
||||||
setIsShowChatFragment(true)
|
|
||||||
}}
|
|
||||||
display={navigationItemSelected == "Recents"}
|
|
||||||
currentChatId={currentChatId}
|
|
||||||
recentsList={recentsList}
|
|
||||||
setRecentsList={setRecentsList} />
|
|
||||||
}
|
|
||||||
{
|
|
||||||
// 對話列表
|
|
||||||
<ContactsList
|
|
||||||
setChatInfo={setChatInfo}
|
|
||||||
addContactDialogRef={addContactDialogRef as any}
|
|
||||||
chatInfoDialogRef={chatInfoDialogRef as any}
|
|
||||||
display={navigationItemSelected == "Contacts"} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<mdui-navigation-bar label-visibility="selected" value="Recents" ref={navigationBarRef} 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="chat--outlined" active-icon="chat--filled" value="Contacts">對話</mdui-navigation-bar-item>
|
|
||||||
</mdui-navigation-bar>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,9 @@ export default function Avatar({
|
|||||||
avatarRef,
|
avatarRef,
|
||||||
...props
|
...props
|
||||||
}: Args) {
|
}: Args) {
|
||||||
if (src != null)
|
if (src != null && src != '')
|
||||||
return <mdui-avatar ref={avatarRef} {...props} src={src} />
|
return <mdui-avatar ref={avatarRef} {...props} src={src} />
|
||||||
else if (text != null)
|
else if (text != null && text != '')
|
||||||
return <mdui-avatar ref={avatarRef} {...props}>
|
return <mdui-avatar ref={avatarRef} {...props}>
|
||||||
{
|
{
|
||||||
text.substring(0, 1)
|
text.substring(0, 1)
|
||||||
|
|||||||
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,419 +0,0 @@
|
|||||||
import { Tab, TextField } from "mdui"
|
|
||||||
import { $ } from "mdui/jq"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import Element_Message from "./Message.tsx"
|
|
||||||
import MessageContainer from "./MessageContainer.tsx"
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import Client from "../../api/Client.ts"
|
|
||||||
import Message from "../../api/client_data/Message.ts"
|
|
||||||
import Chat from "../../api/client_data/Chat.ts"
|
|
||||||
import data from "../../Data.ts"
|
|
||||||
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
|
|
||||||
import useAsyncEffect from "../useAsyncEffect.ts"
|
|
||||||
import * as marked from 'marked'
|
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import randomUUID from "../../randomUUID.ts"
|
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
|
||||||
target: string
|
|
||||||
showReturnButton?: boolean
|
|
||||||
onReturnButtonClicked?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const markedInstance = new marked.Marked({
|
|
||||||
renderer: {
|
|
||||||
heading({ tokens, depth: _depth }) {
|
|
||||||
const text = this.parser.parseInline(tokens)
|
|
||||||
return `<span>${text}</span>`
|
|
||||||
},
|
|
||||||
paragraph({ tokens }) {
|
|
||||||
const text = this.parser.parseInline(tokens)
|
|
||||||
return `<span>${text}</span>`
|
|
||||||
},
|
|
||||||
image({ text, href }) {
|
|
||||||
const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
|
|
||||||
if (/uploaded_files\/[A-Za-z0-9]+$/.test(href)) {
|
|
||||||
return ({
|
|
||||||
Image: `<chat-image src="${href}" alt="${text}"></chat-image>`,
|
|
||||||
Video: `<chat-video src="${href}"></chat-video>`,
|
|
||||||
File: `<chat-file href="${href}" name="${/^Video|File=(.*)/.exec(text)?.[1] || 'Unnamed file'}"></chat-file>`,
|
|
||||||
})?.[type] || ``
|
|
||||||
}
|
|
||||||
return ``
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function ChatFragment({ target, showReturnButton, onReturnButtonClicked, ...props }: Args) {
|
|
||||||
const [messagesList, setMessagesList] = React.useState([] as Message[])
|
|
||||||
const [chatInfo, setChatInfo] = React.useState({
|
|
||||||
title: '加載中...'
|
|
||||||
} as Chat)
|
|
||||||
|
|
||||||
const [tabItemSelected, setTabItemSelected] = React.useState('None')
|
|
||||||
const tabRef = React.useRef<Tab>(null)
|
|
||||||
const chatPanelRef = React.useRef<HTMLElement>(null)
|
|
||||||
useEventListener(tabRef, 'change', () => {
|
|
||||||
tabRef.current != null && setTabItemSelected(tabRef.current!.value as string)
|
|
||||||
})
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
const re = await Client.invoke('Chat.getInfo', {
|
|
||||||
token: data.access_token,
|
|
||||||
target: target,
|
|
||||||
})
|
|
||||||
if (re.code != 200)
|
|
||||||
return target != '' && checkApiSuccessOrSncakbar(re, "對話錯誤")
|
|
||||||
setChatInfo(re.data as Chat)
|
|
||||||
|
|
||||||
await loadMore()
|
|
||||||
|
|
||||||
setTabItemSelected("Chat")
|
|
||||||
setTimeout(() => {
|
|
||||||
chatPanelRef.current!.scrollTo({
|
|
||||||
top: 10000000000,
|
|
||||||
behavior: "smooth",
|
|
||||||
})
|
|
||||||
}, 300)
|
|
||||||
}, [target])
|
|
||||||
|
|
||||||
const page = React.useRef(0)
|
|
||||||
async function loadMore() {
|
|
||||||
const re = await Client.invoke("Chat.getMessageHistory", {
|
|
||||||
token: data.access_token,
|
|
||||||
target,
|
|
||||||
page: page.current,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "拉取歷史記錄失敗")) return
|
|
||||||
const returnMsgs = (re.data!.messages as Message[]).reverse()
|
|
||||||
page.current++
|
|
||||||
if (returnMsgs.length == 0) {
|
|
||||||
setShowNoMoreMessagesTip(true)
|
|
||||||
setTimeout(() => setShowNoMoreMessagesTip(false), 1000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldest = messagesList[0]
|
|
||||||
setMessagesList(returnMsgs.concat(messagesList))
|
|
||||||
setTimeout(() => chatPanelRef.current!.scrollTo({ top: $(`#chat_${target}_message_${oldest.id}`).get(0).offsetTop, behavior: 'smooth' }), 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
interface OnMessageData {
|
|
||||||
chat: string
|
|
||||||
msg: Message
|
|
||||||
}
|
|
||||||
function callback(data: unknown) {
|
|
||||||
const { chat, msg } = (data as OnMessageData)
|
|
||||||
if (target == chat) {
|
|
||||||
setMessagesList(messagesList.concat([msg]))
|
|
||||||
if ((chatPanelRef.current!.scrollHeight - chatPanelRef.current!.scrollTop - chatPanelRef.current!.clientHeight) < 130)
|
|
||||||
setTimeout(() => chatPanelRef.current!.scrollTo({
|
|
||||||
top: 10000000000,
|
|
||||||
behavior: "smooth",
|
|
||||||
}), 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Client.on('Client.onMessage', callback)
|
|
||||||
return () => {
|
|
||||||
Client.off('Client.onMessage', callback)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputRef = React.useRef<TextField>(null)
|
|
||||||
const [showLoadingMoreMessagesTip, setShowLoadingMoreMessagesTip] = React.useState(false)
|
|
||||||
const [showNoMoreMessagesTip, setShowNoMoreMessagesTip] = React.useState(false)
|
|
||||||
|
|
||||||
const [isMessageSending, setIsMessageSending] = React.useState(false)
|
|
||||||
|
|
||||||
const cachedFiles = React.useRef({} as { [fileName: string]: ArrayBuffer })
|
|
||||||
const cachedFileNamesCount = React.useRef({} as { [fileName: string]: number })
|
|
||||||
async function sendMessage() {
|
|
||||||
try {
|
|
||||||
let text = inputRef.current!.value
|
|
||||||
if (text.trim() == '') return
|
|
||||||
setIsMessageSending(true)
|
|
||||||
for (const fileName of Object.keys(cachedFiles.current)) {
|
|
||||||
if (text.indexOf(fileName) != -1) {
|
|
||||||
const re = await Client.invoke("Chat.uploadFile", {
|
|
||||||
token: data.access_token,
|
|
||||||
file_name: fileName,
|
|
||||||
target,
|
|
||||||
data: cachedFiles.current[fileName],
|
|
||||||
}, 5000)
|
|
||||||
if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上傳失敗`)) return setIsMessageSending(false)
|
|
||||||
text = text.replaceAll('(' + fileName + ')', '(' + re.data!.file_path as string + ')')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const re = await Client.invoke("Chat.sendMessage", {
|
|
||||||
token: data.access_token,
|
|
||||||
target,
|
|
||||||
text,
|
|
||||||
}, 5000)
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "發送失敗")) return setIsMessageSending(false)
|
|
||||||
inputRef.current!.value = ''
|
|
||||||
cachedFiles.current = {}
|
|
||||||
} catch (e) {
|
|
||||||
snackbar({
|
|
||||||
message: '發送失敗: ' + (e as Error).message,
|
|
||||||
placement: 'top',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setIsMessageSending(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachFileInputRef = React.useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
function insertText(text: string) {
|
|
||||||
const input = inputRef.current!.shadowRoot!.querySelector('[part=input]') as HTMLTextAreaElement
|
|
||||||
inputRef.current!.value = input.value!.substring(0, input.selectionStart as number) + text + input.value!.substring(input.selectionEnd as number, input.value.length)
|
|
||||||
}
|
|
||||||
async function addFile(type: string, name_: string, data: Blob | Response) {
|
|
||||||
let name = name_
|
|
||||||
while (cachedFiles.current[name] != null) {
|
|
||||||
name = name_ + '_' + cachedFileNamesCount.current[name]
|
|
||||||
cachedFileNamesCount.current[name]++
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedFiles.current[name] = await data.arrayBuffer()
|
|
||||||
cachedFileNamesCount.current[name] = 1
|
|
||||||
if (type.startsWith('image/'))
|
|
||||||
insertText(``)
|
|
||||||
else if (type.startsWith('video/'))
|
|
||||||
insertText(``)
|
|
||||||
else
|
|
||||||
insertText(``)
|
|
||||||
}
|
|
||||||
useEventListener(attachFileInputRef, 'change', (_e) => {
|
|
||||||
const files = attachFileInputRef.current!.files as unknown as File[]
|
|
||||||
if (files?.length == 0) return
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
addFile(file.type, file.name, file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflowY: 'auto',
|
|
||||||
}} {...props}>
|
|
||||||
<mdui-tabs ref={tabRef} value={tabItemSelected} style={{
|
|
||||||
position: 'sticky',
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
}}>
|
|
||||||
{
|
|
||||||
showReturnButton && <mdui-button-icon icon="arrow_back" onClick={onReturnButtonClicked} style={{
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginLeft: '5px',
|
|
||||||
marginRight: '5px',
|
|
||||||
}}></mdui-button-icon>
|
|
||||||
}
|
|
||||||
<mdui-tab value="Chat">{
|
|
||||||
chatInfo.title
|
|
||||||
}</mdui-tab>
|
|
||||||
<mdui-tab value="Settings">設定</mdui-tab>
|
|
||||||
<mdui-tab value="None" style={{ display: 'none' }}></mdui-tab>
|
|
||||||
|
|
||||||
<mdui-tab-panel slot="panel" value="Chat" ref={chatPanelRef} style={{
|
|
||||||
display: tabItemSelected == "Chat" ? "flex" : "none",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
}} onScroll={async (e) => {
|
|
||||||
const scrollTop = (e.target as HTMLDivElement).scrollTop
|
|
||||||
if (scrollTop == 0 && !showLoadingMoreMessagesTip) {
|
|
||||||
setShowLoadingMoreMessagesTip(true)
|
|
||||||
await loadMore()
|
|
||||||
setShowLoadingMoreMessagesTip(false)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingTop: "15px",
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: showLoadingMoreMessagesTip ? 'flex' : 'none',
|
|
||||||
}}>
|
|
||||||
<mdui-circular-progress style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
}}></mdui-circular-progress>
|
|
||||||
<span style={{
|
|
||||||
alignSelf: 'center',
|
|
||||||
paddingLeft: '12px',
|
|
||||||
}}>加載中...</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
display: showNoMoreMessagesTip ? undefined : 'none',
|
|
||||||
alignSelf: 'center',
|
|
||||||
}}>
|
|
||||||
沒有更多消息啦~
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MessageContainer style={{
|
|
||||||
paddingTop: "15px",
|
|
||||||
flexGrow: '1',
|
|
||||||
}}>
|
|
||||||
{
|
|
||||||
(() => {
|
|
||||||
let date = new Date(0)
|
|
||||||
return messagesList.map((msg) => {
|
|
||||||
const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
"chat-image",
|
|
||||||
"chat-video",
|
|
||||||
"chat-file",
|
|
||||||
"span",
|
|
||||||
"chat-link",
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [
|
|
||||||
'src',
|
|
||||||
'alt',
|
|
||||||
'href',
|
|
||||||
'name',
|
|
||||||
],
|
|
||||||
}).replaceAll('\n', '<br>')
|
|
||||||
const lastDate = date
|
|
||||||
date = new Date(msg.time)
|
|
||||||
|
|
||||||
const msgElement = <Element_Message
|
|
||||||
rawData={msg.text}
|
|
||||||
renderHTML={rendeText}
|
|
||||||
message={msg}
|
|
||||||
key={msg.id}
|
|
||||||
slot="trigger"
|
|
||||||
id={`chat_${target}_message_${msg.id}`}
|
|
||||||
userId={msg.user_id} />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{
|
|
||||||
(date.getMinutes() != lastDate.getMinutes() || date.getDate() != lastDate.getDate() || date.getMonth() != lastDate.getMonth() || date.getFullYear() != lastDate.getFullYear())
|
|
||||||
&& <mdui-tooltip content={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '87%',
|
|
||||||
marginTop: '10px',
|
|
||||||
}}>
|
|
||||||
{
|
|
||||||
(date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}年` : '')
|
|
||||||
+ `${date.getMonth() + 1}月`
|
|
||||||
+ `${date.getDate()}日`
|
|
||||||
+ ` ${date.getHours()}:${date.getMinutes()}`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</mdui-tooltip>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
msgElement
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
</MessageContainer>
|
|
||||||
{
|
|
||||||
// 输入框
|
|
||||||
}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingBottom: '2px',
|
|
||||||
paddingTop: '0.1rem',
|
|
||||||
position: 'sticky',
|
|
||||||
bottom: '0',
|
|
||||||
paddingLeft: '5px',
|
|
||||||
paddingRight: '4px',
|
|
||||||
backgroundColor: 'rgb(var(--mdui-color-surface))',
|
|
||||||
}} onDrop={(e) => {
|
|
||||||
function getFileNameOrRandom(urlString: string) {
|
|
||||||
const url = new URL(urlString)
|
|
||||||
let filename = url.pathname.substring(url.pathname.lastIndexOf('/') + 1).trim()
|
|
||||||
if (filename == '')
|
|
||||||
filename = 'file_' + randomUUID()
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
if (e.dataTransfer.items.length > 0) {
|
|
||||||
// 基于当前的实现, 浏览器不会读取文件的字节流来确定其媒体类型, 其根据文件扩展名进行假设
|
|
||||||
// https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/type
|
|
||||||
for (const item of e.dataTransfer.items) {
|
|
||||||
if (item.type == 'text/uri-list') {
|
|
||||||
item.getAsString(async (url) => {
|
|
||||||
try {
|
|
||||||
// 即便是 no-cors 還是殘廢, 因此暫時沒有什麽想法
|
|
||||||
const re = await fetch(url)
|
|
||||||
const type = re.headers.get("Content-Type")
|
|
||||||
if (type && re.ok)
|
|
||||||
addFile(type as string, getFileNameOrRandom(url), re)
|
|
||||||
} catch (e) {
|
|
||||||
snackbar({
|
|
||||||
message: '無法解析連結: ' + (e as Error).message,
|
|
||||||
placement: 'top',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (item.kind == 'file') {
|
|
||||||
e.preventDefault()
|
|
||||||
const file = item.getAsFile() as File
|
|
||||||
addFile(item.type, file.name, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize ref={inputRef as any} max-rows={6} onChange={() => {
|
|
||||||
if (inputRef.current?.value.trim() == '')
|
|
||||||
cachedFiles.current = {}
|
|
||||||
}} onKeyDown={(event) => {
|
|
||||||
if (event.ctrlKey && event.key == 'Enter')
|
|
||||||
sendMessage()
|
|
||||||
}} onPaste={(event) => {
|
|
||||||
for (const item of event.clipboardData.items) {
|
|
||||||
if (item.kind == 'file') {
|
|
||||||
event.preventDefault()
|
|
||||||
const file = item.getAsFile() as File
|
|
||||||
addFile(item.type, file.name, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} style={{
|
|
||||||
marginRight: '10px',
|
|
||||||
marginTop: '3px',
|
|
||||||
marginBottom: '3px',
|
|
||||||
}}></mdui-text-field>
|
|
||||||
<mdui-button-icon slot="end-icon" icon="attach_file" style={{
|
|
||||||
marginRight: '6px',
|
|
||||||
}} onClick={() => {
|
|
||||||
attachFileInputRef.current!.click()
|
|
||||||
}}></mdui-button-icon>
|
|
||||||
<mdui-button-icon icon="send" style={{
|
|
||||||
marginRight: '7px',
|
|
||||||
}} onClick={() => sendMessage()} loading={isMessageSending}></mdui-button-icon>
|
|
||||||
<div style={{
|
|
||||||
display: 'none'
|
|
||||||
}}>
|
|
||||||
<input accept="*/*" type="file" name="選擇附加文檔" multiple ref={attachFileInputRef}></input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mdui-tab-panel>
|
|
||||||
<mdui-tab-panel slot="panel" value="Settings" style={{
|
|
||||||
display: tabItemSelected == "Settings" ? "flex" : "none",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
}}>
|
|
||||||
Work in progress...
|
|
||||||
</mdui-tab-panel>
|
|
||||||
<mdui-tab-panel slot="panel" value="None">
|
|
||||||
</mdui-tab-panel>
|
|
||||||
</mdui-tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Dropdown, Dialog } from "mdui"
|
|
||||||
import { $ } from "mdui/jq"
|
|
||||||
import Client from "../../api/Client.ts"
|
|
||||||
import Data_Message from "../../api/client_data/Message.ts"
|
|
||||||
import DataCaches from "../../api/DataCaches.ts"
|
|
||||||
import Avatar from "../Avatar.tsx"
|
|
||||||
import copyToClipboard from "../copyToClipboard.ts"
|
|
||||||
import useAsyncEffect from "../useAsyncEffect.ts"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import React from "react"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import isMobileUI from "../isMobileUI.ts"
|
|
||||||
import ReactJson from 'react-json-view'
|
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
|
||||||
userId: string
|
|
||||||
rawData: string
|
|
||||||
renderHTML: string
|
|
||||||
message: Data_Message
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Message({ userId, rawData, renderHTML, message, ...props }: Args) {
|
|
||||||
const isAtRight = Client.myUserProfile?.id == userId
|
|
||||||
|
|
||||||
const [nickName, setNickName] = React.useState("")
|
|
||||||
const [avatarUrl, setAvatarUrl] = React.useState<string | undefined>("")
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
const user = await DataCaches.getUserProfile(userId)
|
|
||||||
setNickName(user.nickname)
|
|
||||||
setAvatarUrl(user?.avatar)
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
const dropDownRef = React.useRef<Dropdown>(null)
|
|
||||||
const messageJsonDialogRef = React.useRef<Dialog>(null)
|
|
||||||
useEventListener(messageJsonDialogRef, 'click', (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
})
|
|
||||||
|
|
||||||
const [isDropDownOpen, setDropDownOpen] = React.useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
slot="trigger"
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
if (isMobileUI()) return
|
|
||||||
e.preventDefault()
|
|
||||||
setDropDownOpen(!isDropDownOpen)
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!isMobileUI()) return
|
|
||||||
e.preventDefault()
|
|
||||||
setDropDownOpen(!isDropDownOpen)
|
|
||||||
}}
|
|
||||||
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={avatarUrl}
|
|
||||||
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",
|
|
||||||
}}>
|
|
||||||
<mdui-dialog close-on-overlay-click close-on-esc ref={messageJsonDialogRef}>
|
|
||||||
<ReactJson src={message} />
|
|
||||||
</mdui-dialog>
|
|
||||||
<mdui-dropdown trigger="manual" ref={dropDownRef} open={isDropDownOpen}>
|
|
||||||
<span
|
|
||||||
slot="trigger"
|
|
||||||
id="msg"
|
|
||||||
style={{
|
|
||||||
fontSize: "94%"
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderHTML
|
|
||||||
}} />
|
|
||||||
<mdui-menu onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDropDownOpen(false)
|
|
||||||
}}>
|
|
||||||
<mdui-menu-item icon="content_copy" onClick={() => copyToClipboard($(dropDownRef.current as HTMLElement).find('#msg').text())}>複製文字</mdui-menu-item>
|
|
||||||
<mdui-menu-item icon="content_copy" onClick={() => copyToClipboard(rawData)}>複製原文</mdui-menu-item>
|
|
||||||
<mdui-menu-item icon="info" onClick={() => messageJsonDialogRef.current.open = true}>查看詳情</mdui-menu-item>
|
|
||||||
</mdui-menu>
|
|
||||||
</mdui-dropdown>
|
|
||||||
</mdui-card>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
interface Args extends React.HTMLAttributes<HTMLElement> {}
|
|
||||||
|
|
||||||
export default function MessageContainer({ children, style, ...props }: Args) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '20px',
|
|
||||||
...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,20 +0,0 @@
|
|||||||
export default function copyToClipboard(text: string) {
|
|
||||||
if (navigator.clipboard)
|
|
||||||
return navigator.clipboard.writeText(text)
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
if (document.hasFocus()) {
|
|
||||||
const a = document.createElement("textarea")
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.style.position = "fixed"
|
|
||||||
a.style.clip = "rect(0 0 0 0)"
|
|
||||||
a.style.top = "10px"
|
|
||||||
a.value = text
|
|
||||||
a.select()
|
|
||||||
document.execCommand("copy", true)
|
|
||||||
document.body.removeChild(a)
|
|
||||||
res(null)
|
|
||||||
} else {
|
|
||||||
rej()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { $ } from 'mdui/jq'
|
|
||||||
|
|
||||||
customElements.define('chat-file', class extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
connectedCallback() {
|
|
||||||
const e = new DOMParser().parseFromString(`
|
|
||||||
<a style="width: 100%;height: 100%;">
|
|
||||||
<mdui-card variant="outlined" clickable style="display: flex;align-items: center;">
|
|
||||||
<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 HTMLElement
|
|
||||||
$(e).find('span').text($(this).attr("name"))
|
|
||||||
const href = $(this).attr('href')
|
|
||||||
$(e).attr('href', href)
|
|
||||||
$(e).attr('target', '_blank')
|
|
||||||
$(e).attr('download', href)
|
|
||||||
e.style.textDecoration = 'none'
|
|
||||||
e.style.color = 'inherit'
|
|
||||||
// deno-lint-ignore no-window
|
|
||||||
e.onclick = (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
this.appendChild(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import openImageViewer from "../openImageViewer.ts"
|
|
||||||
import { snackbar } from "../snackbar.ts"
|
|
||||||
|
|
||||||
import { $ } from 'mdui/jq'
|
|
||||||
|
|
||||||
|
|
||||||
customElements.define('chat-image', class extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
connectedCallback() {
|
|
||||||
this.style.display = 'block'
|
|
||||||
const e = new Image()
|
|
||||||
e.style.maxWidth = "100%"
|
|
||||||
e.style.maxHeight = "90%"
|
|
||||||
e.style.marginTop = '5px'
|
|
||||||
e.style.marginBottom = '5px'
|
|
||||||
e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
|
||||||
e.alt = $(this).attr('alt') || ""
|
|
||||||
e.onerror = () => {
|
|
||||||
const src = $(this).attr('src')
|
|
||||||
$(this).html(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`)
|
|
||||||
$(this).attr('alt', '無法加載圖像')
|
|
||||||
$(this).on('click', () => {
|
|
||||||
snackbar({
|
|
||||||
message: `圖片 (${src}) 無法加載!`,
|
|
||||||
placement: 'top'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
e.src = $(this).attr('src') as string
|
|
||||||
e.onclick = (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
openImageViewer($(this).attr('src') as string)
|
|
||||||
}
|
|
||||||
this.appendChild(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
document.body.appendChild(new DOMParser().parseFromString(`
|
|
||||||
<mdui-dialog id="image-viewer-dialog" fullscreen="fullscreen">
|
|
||||||
<style>
|
|
||||||
#image-viewer-dialog::part(panel) {
|
|
||||||
background: rgba(0, 0, 0, 0) !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#image-viewer-dialog>mdui-button-icon[icon=close] {
|
|
||||||
z-index: 114514;
|
|
||||||
position: fixed;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
color: #ffffff
|
|
||||||
}
|
|
||||||
|
|
||||||
#image-viewer-dialog>mdui-button-icon[icon=open_in_new] {
|
|
||||||
z-index: 114514;
|
|
||||||
position: fixed;
|
|
||||||
top: 15px;
|
|
||||||
right: 65px;
|
|
||||||
color: #ffffff
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<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="this.parentNode.open = false">
|
|
||||||
</mdui-button-icon>
|
|
||||||
<pinch-zoom id="image-viewer-dialog-inner" style="width: var(--whitesilk-window-width); height: var(--whitesilk-window-height);">
|
|
||||||
</pinch-zoom>
|
|
||||||
</mdui-dialog>
|
|
||||||
`, 'text/html').body.firstChild as Node)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { $ } from 'mdui/jq'
|
|
||||||
|
|
||||||
customElements.define('chat-video', class extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
connectedCallback() {
|
|
||||||
this.style.display = 'block'
|
|
||||||
const e = new DOMParser().parseFromString(`<video controls>視頻無法播放</video>`, 'text/html').body.firstChild as HTMLVideoElement
|
|
||||||
e.style.width = "100%"
|
|
||||||
e.style.height = "100%"
|
|
||||||
e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
|
||||||
e.src = $(this).attr('src') as string
|
|
||||||
e.onclick = (e) => e.stopPropagation()
|
|
||||||
this.appendChild(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { Button, Dialog, TextField } from "mdui"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
|
|
||||||
import Client from "../../api/Client.ts"
|
|
||||||
|
|
||||||
import * as CryptoJS from 'crypto-js'
|
|
||||||
import data from "../../Data.ts"
|
|
||||||
import EventBus from "../../EventBus.ts"
|
|
||||||
|
|
||||||
interface Refs {
|
|
||||||
addContactDialogRef: React.MutableRefObject<Dialog | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddContactDialog({
|
|
||||||
addContactDialogRef,
|
|
||||||
}: Refs) {
|
|
||||||
const inputUserAccountRef = React.useRef<TextField>(null)
|
|
||||||
|
|
||||||
async function addContact() {
|
|
||||||
const re = await Client.invoke("User.addContact", {
|
|
||||||
account: inputUserAccountRef.current!.value,
|
|
||||||
token: data.access_token,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "添加失敗")) return
|
|
||||||
snackbar({
|
|
||||||
message: "添加成功!",
|
|
||||||
placement: "top",
|
|
||||||
})
|
|
||||||
EventBus.emit('ContactsList.updateContacts')
|
|
||||||
|
|
||||||
inputUserAccountRef.current!.value = ''
|
|
||||||
addContactDialogRef.current!.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mdui-dialog close-on-overlay-click close-on-esc headline="添加對話" ref={addContactDialogRef}>
|
|
||||||
現階段只支持添加用戶, 對話敬請期待...
|
|
||||||
<mdui-text-field style={{ marginTop: "10px", }} clearable label="對方的 用戶 ID / 用戶名" ref={inputUserAccountRef as any} onKeyDown={(event) => {
|
|
||||||
if (event.key == 'Enter')
|
|
||||||
addContact()
|
|
||||||
}}></mdui-text-field>
|
|
||||||
<mdui-button slot="action" variant="text" onClick={() => addContactDialogRef.current!.open = false}>取消</mdui-button>
|
|
||||||
<mdui-button slot="action" variant="text" onClick={() => addContact()}>添加</mdui-button>
|
|
||||||
</mdui-dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import Chat from "../../api/client_data/Chat.ts"
|
|
||||||
import useAsyncEffect from "../useAsyncEffect.ts"
|
|
||||||
import Client from "../../api/Client.ts"
|
|
||||||
import data from "../../Data.ts"
|
|
||||||
import { Dialog } from "mdui"
|
|
||||||
import Avatar from "../Avatar.tsx";
|
|
||||||
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
|
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
|
||||||
chat: Chat
|
|
||||||
openChatFragment: (id: string) => void
|
|
||||||
chatInfoDialogRef: React.MutableRefObject<Dialog>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatInfoDialog({ chat, chatInfoDialogRef, openChatFragment }: Args) {
|
|
||||||
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
|
|
||||||
const isMySelf = Client.myUserProfile?.id == chatInfo?.user_a_id && Client.myUserProfile?.id == chatInfo?.user_b_id
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
if (chat == null) return
|
|
||||||
const re = await Client.invoke("Chat.getInfo", {
|
|
||||||
token: data.access_token,
|
|
||||||
target: chat.id,
|
|
||||||
})
|
|
||||||
if (re.code != 200)
|
|
||||||
return checkApiSuccessOrSncakbar(re, '獲取對話訊息失敗')
|
|
||||||
setChatInfo(re.data!.chat_info as Chat)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mdui-dialog close-on-overlay-click close-on-esc ref={chatInfoDialogRef}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<Avatar src={chat?.avatar as string} text={chat?.nickname as string} style={{
|
|
||||||
width: '50px',
|
|
||||||
height: '50px',
|
|
||||||
}} />
|
|
||||||
<span style={{
|
|
||||||
marginLeft: "15px",
|
|
||||||
fontSize: '16.5px',
|
|
||||||
}}>{chat?.title}</span>
|
|
||||||
</div>
|
|
||||||
<mdui-divider style={{
|
|
||||||
marginTop: "10px",
|
|
||||||
marginBottom: "10px",
|
|
||||||
}}></mdui-divider>
|
|
||||||
|
|
||||||
<mdui-list>
|
|
||||||
<mdui-list-item icon="chat" rounded onClick={() => {
|
|
||||||
chatInfoDialogRef.current!.open = false
|
|
||||||
openChatFragment(chat.id)
|
|
||||||
}}>對話</mdui-list-item>
|
|
||||||
</mdui-list>
|
|
||||||
</mdui-dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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.useRef<Button>(null)
|
|
||||||
const registerButtonRef = React.useRef<Button>(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 as any}></mdui-text-field>
|
|
||||||
<div style={{
|
|
||||||
height: "10px",
|
|
||||||
}}></div>
|
|
||||||
<mdui-text-field label="密碼" type="password" toggle-password ref={loginInputPasswordRef as any}></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,152 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { Button, Dialog, TextField, dialog } from "mdui"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
|
|
||||||
import Client from "../../api/Client.ts"
|
|
||||||
|
|
||||||
import * as CryptoJS from 'crypto-js'
|
|
||||||
import data from "../../Data.ts"
|
|
||||||
import Avatar from "../Avatar.tsx"
|
|
||||||
import User from "../../api/client_data/User.ts"
|
|
||||||
|
|
||||||
interface Refs {
|
|
||||||
myProfileDialogRef: React.MutableRefObject<Dialog>
|
|
||||||
user: User
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MyProfileDialog({
|
|
||||||
myProfileDialogRef,
|
|
||||||
user
|
|
||||||
}: Refs) {
|
|
||||||
const isMySelf = Client.myUserProfile?.id == user?.id
|
|
||||||
|
|
||||||
const editAvatarButtonRef = React.useRef<HTMLElement>(null)
|
|
||||||
const chooseAvatarFileRef = React.useRef<HTMLInputElement>(null)
|
|
||||||
useEventListener(editAvatarButtonRef, 'click', () => chooseAvatarFileRef.current!.click())
|
|
||||||
useEventListener(chooseAvatarFileRef, 'change', async (_e) => {
|
|
||||||
const file = chooseAvatarFileRef.current!.files?.[0] as File
|
|
||||||
if (file == null) return
|
|
||||||
|
|
||||||
const re = await Client.invoke("User.setAvatar", {
|
|
||||||
token: data.access_token,
|
|
||||||
avatar: file
|
|
||||||
})
|
|
||||||
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "修改失敗")) return
|
|
||||||
snackbar({
|
|
||||||
message: "修改成功 (刷新頁面以更新)",
|
|
||||||
placement: "top",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const userProfileEditDialogRef = React.useRef<Dialog>(null)
|
|
||||||
const editNickNameRef = React.useRef<TextField>(null)
|
|
||||||
const editUserNameRef = React.useRef<TextField>(null)
|
|
||||||
|
|
||||||
return (<>
|
|
||||||
{
|
|
||||||
// 公用 - 資料卡
|
|
||||||
}
|
|
||||||
<mdui-dialog close-on-overlay-click close-on-esc ref={myProfileDialogRef}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<Avatar src={user?.avatar} text={user?.nickname} style={{
|
|
||||||
width: '50px',
|
|
||||||
height: '50px',
|
|
||||||
}} />
|
|
||||||
<span style={{
|
|
||||||
marginLeft: "15px",
|
|
||||||
fontSize: '16.5px',
|
|
||||||
}}>{user?.nickname}</span>
|
|
||||||
</div>
|
|
||||||
<mdui-divider style={{
|
|
||||||
marginTop: "10px",
|
|
||||||
}}></mdui-divider>
|
|
||||||
|
|
||||||
<mdui-list>
|
|
||||||
<mdui-list-item icon="edit" rounded onClick={() => userProfileEditDialogRef.current!.open = true}>編輯資料</mdui-list-item>
|
|
||||||
{/*
|
|
||||||
<mdui-list-item icon="settings" rounded>賬號設定</mdui-list-item>
|
|
||||||
<mdui-list-item icon="lock" rounded>隱私設定</mdui-list-item>
|
|
||||||
*/}
|
|
||||||
<mdui-divider style={{
|
|
||||||
marginTop: "10px",
|
|
||||||
marginBottom: "10px",
|
|
||||||
}}></mdui-divider>
|
|
||||||
<mdui-list-item icon="logout" rounded onClick={() => dialog({
|
|
||||||
headline: "退出登錄",
|
|
||||||
description: "確定要退出登錄嗎? (若您的賬號未設定 用戶名, 請無務必複製 用戶 ID, 以免丟失賬號!)",
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
text: "取消",
|
|
||||||
onClick: () => {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "確定",
|
|
||||||
onClick: () => {
|
|
||||||
data.access_token = ''
|
|
||||||
data.apply()
|
|
||||||
location.reload()
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
})}>退出登錄</mdui-list-item>
|
|
||||||
</mdui-list>
|
|
||||||
</mdui-dialog>
|
|
||||||
{
|
|
||||||
// 個人資料編輯
|
|
||||||
}
|
|
||||||
<mdui-dialog close-on-overlay-click close-on-esc ref={userProfileEditDialogRef}>
|
|
||||||
<div style={{
|
|
||||||
display: "none"
|
|
||||||
}}>
|
|
||||||
<input type="file" name="選擇頭像" ref={chooseAvatarFileRef}
|
|
||||||
accept="image/*" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<Avatar src={user?.avatar} text={user?.nickname} avatarRef={editAvatarButtonRef} style={{
|
|
||||||
width: '50px',
|
|
||||||
height: '50px',
|
|
||||||
}} />
|
|
||||||
<mdui-text-field variant="outlined" placeholder="昵稱" ref={editNickNameRef as any} style={{
|
|
||||||
marginLeft: "15px",
|
|
||||||
}} value={user?.nickname}></mdui-text-field>
|
|
||||||
</div>
|
|
||||||
<mdui-divider style={{
|
|
||||||
marginTop: "10px",
|
|
||||||
}}></mdui-divider>
|
|
||||||
|
|
||||||
<mdui-text-field style={{ marginTop: "10px", }} variant="outlined" label="用戶 ID" value={user?.id || ''} readonly onClick={(e) => {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
input.select()
|
|
||||||
input.setSelectionRange(0, 1145141919810)
|
|
||||||
}}></mdui-text-field>
|
|
||||||
<mdui-text-field style={{ marginTop: "20px", }} variant="outlined" label="用戶名" value={user?.username || ''} ref={editUserNameRef as any}></mdui-text-field>
|
|
||||||
|
|
||||||
<mdui-button slot="action" variant="text" onClick={() => userProfileEditDialogRef.current!.open = false}>取消</mdui-button>
|
|
||||||
<mdui-button slot="action" variant="text" onClick={async () => {
|
|
||||||
const re = await Client.invoke("User.updateProfile", {
|
|
||||||
token: data.access_token,
|
|
||||||
nickname: editNickNameRef.current?.value,
|
|
||||||
username: editUserNameRef.current?.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "修改失敗")) return
|
|
||||||
snackbar({
|
|
||||||
message: "修改成功 (刷新頁面以更新)",
|
|
||||||
placement: "top",
|
|
||||||
})
|
|
||||||
userProfileEditDialogRef.current!.open = false
|
|
||||||
}}>更新</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.useRef<Button>(null)
|
|
||||||
const doRegisterButtonRef = React.useRef<Button>(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 as any}></mdui-text-field>
|
|
||||||
<div style={{
|
|
||||||
height: "10px",
|
|
||||||
}}></div>
|
|
||||||
<mdui-text-field label="昵稱" ref={registerInputNickNameRef as any}></mdui-text-field>
|
|
||||||
<div style={{
|
|
||||||
height: "10px",
|
|
||||||
}}></div>
|
|
||||||
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef as any}></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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function isMobileUI() {
|
|
||||||
return new URL(location.href).searchParams.get('mobile') == 'true'
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
}
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import Chat from "../../api/client_data/Chat.ts"
|
import { $ } from "mdui/jq"
|
||||||
import Avatar from "../Avatar.tsx"
|
import Avatar from "../Avatar.tsx"
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Chat } from "lingchair-client-protocol"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
contact: Chat
|
chat: Chat
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactsListItem({ contact, ...prop }: Args) {
|
export default function AllChatsListItem({ chat, active, ...prop }: Args) {
|
||||||
const { id, title, avatar } = contact
|
const title = chat.getTitle()
|
||||||
|
|
||||||
const ref = React.useRef<HTMLElement>(null)
|
const ref = React.useRef<HTMLElement>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mdui-list-item ref={ref} rounded style={{
|
<mdui-list-item active={active} ref={ref} rounded style={{
|
||||||
marginTop: '3px',
|
marginTop: '3px',
|
||||||
marginBottom: '3px',
|
marginBottom: '3px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -20,7 +23,7 @@ export default function ContactsListItem({ contact, ...prop }: Args) {
|
|||||||
<span style={{
|
<span style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}>{title}</span>
|
}}>{title}</span>
|
||||||
<Avatar src={avatar as string} text={title} slot="icon" />
|
<Avatar src={getClient().getUrlForFileByHash(chat.getAvatarFileHash() as string)} text={title} slot="icon" />
|
||||||
</mdui-list-item>
|
</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>
|
||||||
|
}
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
import RecentChat from "../../api/client_data/RecentChat.ts"
|
import { $ } from "mdui/jq"
|
||||||
import Avatar from "../Avatar.tsx"
|
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> {
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
recentChat: RecentChat
|
recentChat: RecentChat
|
||||||
openChatFragment: (id: string) => void
|
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentsListItem({ recentChat, openChatFragment, active }: Args) {
|
export default function RecentsListItem({ recentChat, active, ...props }: Args) {
|
||||||
const { id, title, avatar, content } = recentChat
|
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 (
|
return (
|
||||||
<mdui-list-item rounded style={{
|
<mdui-list-item rounded style={{
|
||||||
marginTop: '3px',
|
marginTop: '3px',
|
||||||
marginBottom: '3px',
|
marginBottom: '3px',
|
||||||
}} onClick={() => openChatFragment(id)} active={active}>
|
}} active={active} ref={itemRef} {...props}>
|
||||||
{title}
|
{title}
|
||||||
<Avatar src={avatar} text={title} slot="icon" />
|
<Avatar src={getClient().getUrlForFileByHash(avatar_file_hash!)} text={title} slot="icon" />
|
||||||
<span slot="description"
|
<span slot="description"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
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,95 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import ContactsListItem from "./ContactsListItem.tsx"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import { Dialog, ListItem, TextField } from "mdui"
|
|
||||||
import useAsyncEffect from "../useAsyncEffect.ts"
|
|
||||||
import Client from "../../api/Client.ts"
|
|
||||||
import data from "../../Data.ts"
|
|
||||||
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
|
|
||||||
import Chat from "../../api/client_data/Chat.ts"
|
|
||||||
import EventBus from "../../EventBus.ts"
|
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
|
||||||
display: boolean
|
|
||||||
chatInfoDialogRef: React.MutableRefObject<Dialog>
|
|
||||||
addContactDialogRef: React.MutableRefObject<Dialog>
|
|
||||||
setChatInfo: React.Dispatch<React.SetStateAction<Chat>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContactsList({
|
|
||||||
display,
|
|
||||||
setChatInfo,
|
|
||||||
chatInfoDialogRef,
|
|
||||||
addContactDialogRef,
|
|
||||||
...props
|
|
||||||
}: Args) {
|
|
||||||
const searchRef = React.useRef<HTMLElement>(null)
|
|
||||||
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
|
|
||||||
const [searchText, setSearchText] = React.useState('')
|
|
||||||
const [contactsList, setContactsList] = React.useState<Chat[]>([])
|
|
||||||
|
|
||||||
useEventListener(searchRef, 'input', (e) => {
|
|
||||||
setSearchText((e.target as unknown as TextField).value)
|
|
||||||
})
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
async function updateContacts() {
|
|
||||||
const re = await Client.invoke("User.getMyContacts", {
|
|
||||||
token: data.access_token,
|
|
||||||
})
|
|
||||||
if (re.code != 200)
|
|
||||||
return checkApiSuccessOrSncakbar(re, "获取對話列表失败")
|
|
||||||
|
|
||||||
setContactsList(re.data!.contacts_list as Chat[])
|
|
||||||
}
|
|
||||||
updateContacts()
|
|
||||||
EventBus.on('ContactsList.updateContacts', () => updateContacts())
|
|
||||||
})
|
|
||||||
|
|
||||||
return <mdui-list style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingLeft: '10px',
|
|
||||||
paddingRight: '10px',
|
|
||||||
display: display ? undefined : 'none',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
}} {...props}>
|
|
||||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
|
||||||
marginTop: '5px',
|
|
||||||
}}></mdui-text-field>
|
|
||||||
|
|
||||||
<mdui-list-item rounded style={{
|
|
||||||
width: '100%',
|
|
||||||
marginTop: '13px',
|
|
||||||
}} icon="person_add" onClick={() => addContactDialogRef.current!.open = true}>添加對話</mdui-list-item>
|
|
||||||
<mdui-list-item rounded style={{
|
|
||||||
width: '100%',
|
|
||||||
marginBottom: '15px',
|
|
||||||
}} icon="refresh" onClick={() => EventBus.emit('ContactsList.updateContacts')}>刷新</mdui-list-item>
|
|
||||||
{/* <mdui-list-item rounded style={{
|
|
||||||
width: '100%',
|
|
||||||
marginBottom: '15px',
|
|
||||||
}} icon={ isMultiSelecting ? "done" : "edit"} onClick={() => setIsMultiSelecting(!isMultiSelecting)}>{ isMultiSelecting ? "關閉多選" : "多選模式" }</mdui-list-item> */}
|
|
||||||
|
|
||||||
{
|
|
||||||
contactsList.filter((chat) =>
|
|
||||||
searchText == '' ||
|
|
||||||
chat.title.includes(searchText) ||
|
|
||||||
chat.id.includes(searchText)
|
|
||||||
).map((v) =>
|
|
||||||
<ContactsListItem
|
|
||||||
// active={!isMultiSelecting && false}
|
|
||||||
onClick={(e) => {
|
|
||||||
const self = (e.target as ListItem)
|
|
||||||
/*if (isMultiSelecting)
|
|
||||||
self.active = !self.active
|
|
||||||
else*/
|
|
||||||
setChatInfo(v)
|
|
||||||
chatInfoDialogRef.current!.open = true
|
|
||||||
}}
|
|
||||||
key={v.id}
|
|
||||||
contact={v} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</mdui-list>
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { TextField } from "mdui"
|
|
||||||
import RecentChat from "../../api/client_data/RecentChat.ts"
|
|
||||||
import useEventListener from "../useEventListener.ts"
|
|
||||||
import RecentsListItem from "./RecentsListItem.tsx"
|
|
||||||
import React from "react"
|
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
|
||||||
recentsList: RecentChat[]
|
|
||||||
setRecentsList: React.Dispatch<React.SetStateAction<RecentChat[]>>
|
|
||||||
display: boolean
|
|
||||||
currentChatId: string
|
|
||||||
openChatFragment: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RecentsList({
|
|
||||||
recentsList,
|
|
||||||
setRecentsList,
|
|
||||||
currentChatId,
|
|
||||||
display,
|
|
||||||
openChatFragment,
|
|
||||||
...props
|
|
||||||
}: Args) {
|
|
||||||
const searchRef = React.useRef<HTMLElement>(null)
|
|
||||||
const [searchText, setSearchText] = React.useState('')
|
|
||||||
|
|
||||||
useEventListener(searchRef, 'input', (e) => {
|
|
||||||
setSearchText((e.target as unknown as TextField).value)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <mdui-list style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '10px',
|
|
||||||
paddingLeft: '10px',
|
|
||||||
display: display ? undefined : 'none',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
}} {...props}>
|
|
||||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
|
||||||
marginTop: '5px',
|
|
||||||
marginBottom: '13px',
|
|
||||||
}}></mdui-text-field>
|
|
||||||
{
|
|
||||||
recentsList.filter((chat) =>
|
|
||||||
searchText == '' ||
|
|
||||||
chat.title.includes(searchText) ||
|
|
||||||
chat.id.includes(searchText) ||
|
|
||||||
chat.content?.includes(searchText)
|
|
||||||
).map((v) =>
|
|
||||||
<RecentsListItem
|
|
||||||
active={currentChatId == v.id}
|
|
||||||
openChatFragment={() => openChatFragment(v.id)}
|
|
||||||
key={v.id}
|
|
||||||
recentChat={v} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</mdui-list>
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { $ } from 'mdui/jq'
|
import { $ } from 'mdui/jq'
|
||||||
import 'pinch-zoom-element'
|
|
||||||
export default function openImageViewer(src: string) {
|
export default function openImageViewer(src: string) {
|
||||||
$('#image-viewer-dialog-inner').empty()
|
$('#image-viewer-dialog-inner').empty()
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { snackbar as mduiSnackbar, Snackbar } from "mdui"
|
import { snackbar as mduiSnackbar, Snackbar } from "mdui"
|
||||||
import ApiCallbackMessage from "../api/ApiCallbackMessage.ts"
|
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
/**
|
/**
|
||||||
@@ -83,16 +82,8 @@ interface SnackbarOptions extends Options {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkApiSuccessOrSncakbar(re: ApiCallbackMessage, msg_ahead: string, opinions_override: Options = {}): Snackbar | null {
|
export default function showSnackbar(opinions: SnackbarOptions) {
|
||||||
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 = 3500)
|
opinions.autoCloseDelay == null && (opinions.autoCloseDelay = 3500)
|
||||||
|
opinions.placement == null && (opinions.placement = 'top')
|
||||||
return mduiSnackbar(opinions)
|
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))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react'
|
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(() => {
|
React.useEffect(() => {
|
||||||
ref.current!.addEventListener(eventName, callback)
|
ref.current!.addEventListener(eventName, callback)
|
||||||
return () => ref.current?.removeEventListener(eventName, callback)
|
return () => ref.current?.removeEventListener(eventName, callback)
|
||||||
@@ -1,27 +1,53 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import deno from '@deno/vite-plugin'
|
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import pluginBabel from 'vite-plugin-babel'
|
|
||||||
import config from '../server/config.ts'
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), pluginBabel({
|
plugins: [
|
||||||
babelConfig: {
|
react(),
|
||||||
presets: [
|
nodePolyfills({
|
||||||
[
|
include: ['crypto', 'stream', 'vm'],
|
||||||
'@babel/preset-env',
|
globals: {
|
||||||
{
|
Buffer: true,
|
||||||
targets: {
|
global: true,
|
||||||
android: '70'
|
process: true,
|
||||||
},
|
},
|
||||||
}
|
}),
|
||||||
|
gitHashPlugin(),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
}
|
|
||||||
}), deno()],
|
|
||||||
build: {
|
build: {
|
||||||
cssTarget: 'chrome70',
|
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
outDir: "." + config.data_path + '/page_compiled',
|
outDir: "." + config.data_path + '/page_compiled',
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user