Compare commits

...

410 Commits

Author SHA1 Message Date
CrescentLeaf
20986af1ba (WIP) 重构客户端 2025-12-07 18:31:42 +08:00
CrescentLeaf
34d46a85f1 fix: 蠢到家的一集之 favouritechat 成精成 recentchats 2025-12-07 15:44:17 +08:00
CrescentLeaf
f8f66f0e33 删除暂时用不上的客户端设定图标 2025-12-07 11:08:42 +08:00
CrescentLeaf
58f0427350 去你妈的不区分大小写 2025-12-07 00:47:40 +08:00
CrescentLeaf
e3db26323b 客户端路由不会同步到服务端路由 2025-12-07 00:47:15 +08:00
CrescentLeaf
4788434445 refactor(client): 侧边列表重构 2025-12-07 00:36:51 +08:00
CrescentLeaf
07bc4a6654 头像文字或源为空文本时fallback 2025-12-07 00:30:45 +08:00
CrescentLeaf
bd49edb586 fix: 自我头像无法愉悦 2025-12-07 00:29:57 +08:00
CrescentLeaf
f4a9cc9cda 允许以仅用于调用方法的模式进行部分对象的实例化 2025-12-07 00:29:17 +08:00
CrescentLeaf
8817663371 添加解密失败fallback逻辑 2025-12-07 00:28:08 +08:00
CrescentLeaf
19b8b92f49 修改注释, 添加换行, 删除不必要的代码 2025-12-07 00:07:21 +08:00
CrescentLeaf
f584b49cd4 删除不必要的依赖 2025-12-07 00:06:40 +08:00
CrescentLeaf
13eefdd50c 自动温热一下身份 2025-12-07 00:06:32 +08:00
CrescentLeaf
3cd9031eef 使用更标准的 aes 加密写法, 更换密钥的算法, 限制 data 对象暴露 2025-12-07 00:06:02 +08:00
CrescentLeaf
94c901a233 ignore_all_empty?: boolean
this.refresh_token = args.refresh_token
/**
     * 进行身份验证以接受客户端事件
     *
     * 使用验证方式优先级: 访问 > 刷新 > 账号密码
     *
     * 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
     *
     * 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
     *
     * 多个验证方式不会逐一尝试
     */
2025-12-07 00:03:24 +08:00
CrescentLeaf
1819c31267 updated vite to 7.2.6 2025-12-06 22:51:35 +08:00
CrescentLeaf
00371b1dda fix(client): 移动端界面显示异常 2025-12-06 19:39:23 +08:00
CrescentLeaf
2d48d2f536 feat(client): 登录注册 2025-12-06 17:01:24 +08:00
CrescentLeaf
4214ed9e10 睡觉 2025-12-06 17:01:15 +08:00
CrescentLeaf
198493cac1 等待对话框 2025-12-06 17:01:07 +08:00
CrescentLeaf
f57347b834 共享上下文 2025-12-06 17:00:58 +08:00
CrescentLeaf
f9dff68339 fix: stupid forgetting sha256 2025-12-06 16:58:20 +08:00
CrescentLeaf
48bd884690 fix(cp): 错误的注册方法返回值
* 不是, 我用户 ID 呢
2025-12-06 16:53:35 +08:00
CrescentLeaf
b85b6833b6 添加 react-router, 使 CallackError 获得更多成员, 导出, (WIP) 图片查看器修改, 修复遗忘的 data.apply() 2025-12-06 15:45:43 +08:00
CrescentLeaf
29ea0c5b84 Update 2025-12-06 13:37:25 +08:00
CrescentLeaf
508218a1c5 引入 mdui 类型定义 2025-12-06 13:37:16 +08:00
CrescentLeaf
98774036cd replace icon 2025-12-06 13:36:58 +08:00
CrescentLeaf
e15e1aa4c8 update dockerfile 2025-12-06 13:36:14 +08:00
CrescentLeaf
1c6c0eaf84 移除了无法工作的控制台快捷命令 2025-12-06 11:08:52 +08:00
CrescentLeaf
02b0708426 修改项目配置 2025-12-06 11:08:39 +08:00
CrescentLeaf
d433ceb4a9 抽取 randomUUID, crypto-browserify 2025-12-06 11:08:24 +08:00
CrescentLeaf
d76abcf512 正式回归 Node.js! 2025-12-06 10:17:27 +08:00
CrescentLeaf
6ca9946499 和deno斗争 2025-12-06 01:39:47 +08:00
CrescentLeaf
a549773eb2 TODO: 推翻整个项目重新建立根基 2025-12-06 00:18:10 +08:00
CrescentLeaf
faf594b2f6 export All beans from client protocol main 2025-12-05 22:22:11 +08:00
CrescentLeaf
185f5480fa feat: BlockQoute display in client 2025-12-05 21:27:05 +08:00
CrescentLeaf
b4a60bcbe2 ui: 以正确方式编写 chat-file 的自定义元素代码 2025-12-05 20:49:40 +08:00
CrescentLeaf
d57b023769 feat(unstable): 断开连接时储存事件并重发 2025-11-30 01:45:42 +08:00
CrescentLeaf
4b5f0bcdd6 刷新按钮同样重置页数 2025-11-30 01:45:16 +08:00
CrescentLeaf
3f9ce06ed6 ui: 貌似修了发送消息失败但提示仍在的问题但不知道有没有修好 2025-11-30 01:35:08 +08:00
CrescentLeaf
3def4d7449 fix: 遗漏的 User.getMyAllChats 2025-11-30 00:40:27 +08:00
CrescentLeaf
4b9d78d0d5 ui: 三个侧边列表的搜索框边距修缮 2025-11-30 00:36:15 +08:00
CrescentLeaf
1f6f8a768f ui: 时间显示修缮: 小时和分钟补齐一个 0 2025-11-30 00:32:27 +08:00
CrescentLeaf
a7c61d9306 ui: 不再为每个消息显示发送用户
* 合并显示, 但不完全
2025-11-30 00:32:08 +08:00
CrescentLeaf
0247eaeda9 ui: 对话页面的 Tab 栏项目过多时可以左右滑动, 并保持原有的形态 2025-11-30 00:30:10 +08:00
CrescentLeaf
f9dfa466f0 ui: 加载动画 2025-11-29 13:04:45 +08:00
CrescentLeaf
c2f99f5c62 ui: 列表搜索框扩充微调 2025-11-29 12:52:00 +08:00
CrescentLeaf
6f6dd3bfac ui: 修复了刷新按钮的边距问题 2025-11-29 12:43:33 +08:00
CrescentLeaf
e7f0af8e6e idk 2025-11-29 11:23:27 +08:00
CrescentLeaf
3bd0d79fdc wtf fix int wrong stupid 2025-11-29 01:21:56 +08:00
CrescentLeaf
1e213ddbc4 feat get mimetype 2025-11-29 01:20:48 +08:00
CrescentLeaf
839fb4c4b7 fix: 消息解析不要携带一些有的没有的 2025-11-29 01:05:24 +08:00
CrescentLeaf
35afcf03bb fix: wrong Message instance 2025-11-29 00:56:28 +08:00
CrescentLeaf
5864108f99 fix(cp): marked import 2025-11-29 00:56:08 +08:00
CrescentLeaf
d486c9df79 (client-protocol): 补全解析得到的 附件 和 提及 缺失的文本字段 2025-11-29 00:05:05 +08:00
CrescentLeaf
31e627ce20 fix(cp): 重新断定 parseWithTransformers 返回类型 2025-11-28 23:49:12 +08:00
CrescentLeaf
ca565e3c3e refactor(cp): 客户端事件支持数据对应事件类型 2025-11-28 23:36:47 +08:00
CrescentLeaf
12861b80a1 chore(client-protocol): 抽取获取基 http url 的方法 2025-11-28 23:10:50 +08:00
CrescentLeaf
02b1d28a6b feat(client-protocol): 可以解析消息啦
* 为客户端重构奠定库基础
* 对外提供了比较方便的获取消息附件及提及的方法
2025-11-28 23:10:20 +08:00
CrescentLeaf
f3850a6e2f fix: wrong file_hash get 2025-11-24 23:27:52 +08:00
CrescentLeaf
a9b4a71c0b fix: wrong url join 2025-11-24 22:13:35 +08:00
CrescentLeaf
8df803b3d8 Merge branch 'main' of ssh://codeberg.org/CrescentLeaf/LingChair 2025-11-24 22:12:00 +08:00
CrescentLeaf
2db2bc4c66 fix: wrong panduan 2025-11-24 22:11:52 +08:00
CrescentLeaf
ae837b71aa 修复移动端页面一吸入器页面 2025-11-23 10:17:16 +01:00
CrescentLeaf
fd3684c436 fix 2025-11-23 16:52:48 +08:00
CrescentLeaf
7fcf4ce50b idk 2025-11-23 16:35:18 +08:00
CrescentLeaf
4199335ef8 导出 2025-11-23 14:52:18 +08:00
CrescentLeaf
37281232c0 feat: 自动重新验证 2025-11-23 14:37:05 +08:00
CrescentLeaf
8b3022bed0 1919810 2025-11-23 14:24:50 +08:00
CrescentLeaf
8acf72c7bf 114514 2025-11-23 14:23:49 +08:00
CrescentLeaf
f097a491ae 可以在服务端配置部分客户端行为 目前只作了标题 2025-11-23 14:16:48 +08:00
CrescentLeaf
e90e1911e8 断章取义 2025-11-23 14:16:26 +08:00
CrescentLeaf
d6f1cae7b7 修复了一些配置错误 2025-11-23 13:32:47 +08:00
CrescentLeaf
0754b4128f 抽取公共部分 2025-11-23 13:27:43 +08:00
CrescentLeaf
1cb8ac3fff 移动目录 2025-11-23 13:27:15 +08:00
CrescentLeaf
f13623f4fc 列表不会带动搜索框转 2025-11-23 12:34:18 +08:00
CrescentLeaf
2cf9a20910 snackbar 略改 2025-11-23 12:33:26 +08:00
CrescentLeaf
59191cc42e feat: 查看自己所有的对话 2025-11-23 12:32:59 +08:00
CrescentLeaf
98132eb67c fix: stupid TIMEOUT & INTERVAL mix up 2025-11-23 12:19:32 +08:00
CrescentLeaf
204748699e 对话框默认可以外部点击关闭 2025-11-23 12:05:39 +08:00
CrescentLeaf
7d90d4b0f0 修改了消息原始数据的显示方式 2025-11-23 12:02:59 +08:00
CrescentLeaf
744f02677d Tab 栏样式修改之移除 ::after 2025-11-23 11:15:08 +08:00
CrescentLeaf
8fd9f21c78 todo: Tab 栏样式修改之移除 ::after 2025-11-23 01:14:16 +08:00
CrescentLeaf
5dfcf7a621 资料卡显示对话类型 2025-11-23 00:52:26 +08:00
CrescentLeaf
65602f09f2 资料卡展示对话或用户 id 2025-11-23 00:42:12 +08:00
CrescentLeaf
02d6ee4102 移除 UserProfileDialog 并入 ChatInfoDialog 2025-11-23 00:14:52 +08:00
CrescentLeaf
c9fffbeb12 修复由于没有语法检查导致的一系列符号丢失问题, 支持打开群成员的资料卡 2025-11-22 23:51:36 +08:00
CrescentLeaf
ce692bb763 只有群管理才能显示入群请求页面 2025-11-22 23:35:21 +08:00
CrescentLeaf
1e7e175389 feat: 删除群成员组 2025-11-22 21:50:08 +08:00
CrescentLeaf
c9d9dd8144 修缮了 ChatFragment 可能存在的性能问题 2025-11-22 11:26:19 +08:00
CrescentLeaf
03f8facde0 fix: 非 json 格式错误无法展示 2025-11-22 02:07:34 +08:00
CrescentLeaf
da4325475c 为数据库创建索引 2025-11-22 01:29:49 +08:00
CrescentLeaf
4cb7522251 feat: 群组成员列表 2025-11-22 01:25:29 +08:00
CrescentLeaf
578b3507fd 更新接口定义 2025-11-21 23:44:52 +08:00
CrescentLeaf
b976fed8e7 为 用户-对话 关联表添加索引 2025-11-21 23:15:11 +08:00
CrescentLeaf
48382c4592 修复了对话详情的快捷收藏对话无法正常工作的问题 2025-11-21 23:14:52 +08:00
CrescentLeaf
095b454539 todo: textfield, 但是不是 textarea 而是自定义的输入框 2025-11-21 22:34:27 +08:00
CrescentLeaf
cbdccfb5a7 修缮 snack 2025-11-21 21:52:17 +08:00
CrescentLeaf
32719b45ea 默认自动识别 Android / iOS 设备 2025-11-21 21:46:21 +08:00
CrescentLeaf
b32f60d94d 阻止提及文本点击事件冒泡 2025-11-21 21:38:15 +08:00
CrescentLeaf
d524304b29 ui: 提及 使用 a 而不是 span 2025-11-21 21:28:21 +08:00
CrescentLeaf
7689ec590a fix: 多数据类型消息元素之间的混合显示问题 2025-11-17 00:07:38 +08:00
CrescentLeaf
6517b04215 fix: 移除 chat-mention 换行支持 2025-11-17 00:07:15 +08:00
CrescentLeaf
51fbdc0f71 chore: 修改消息无效附加数据的提示文本 2025-11-17 00:05:10 +08:00
CrescentLeaf
4bf55749bb fix: 避免不同的消息类型之间的换行符导致显示异常 2025-11-17 00:04:45 +08:00
CrescentLeaf
9e8c9bc508 fix: chat-mention 被消去 2025-11-16 22:06:39 +08:00
CrescentLeaf
e1039703d1 修改控制台提示 2025-11-16 21:59:52 +08:00
CrescentLeaf
ace3f8c4f9 feat: 提及某个对话或用户
* 暂时不支持提醒某个在对话内的用户
2025-11-16 21:59:39 +08:00
CrescentLeaf
30c09d0613 fix: 文件文字文件消息, 但是文字(trim)为空导致的显示问题 2025-11-16 21:58:59 +08:00
CrescentLeaf
dec9068cc8 导出 openUserInfoDialog openChatInfoDialog 到 window
* 无奈之举
2025-11-16 19:31:16 +08:00
CrescentLeaf
19cfd84e7d fix: 错误的 openUserInfoDialog 参数类型判断 2025-11-16 19:30:36 +08:00
CrescentLeaf
d00dfab898 die 2025-11-15 00:36:03 +08:00
CrescentLeaf
be27894f95 todo: sendingFileSnackbar died 2025-11-14 23:59:55 +08:00
CrescentLeaf
9b0d91a615 无意义 2025-11-14 22:35:00 +08:00
CrescentLeaf
ad2fd93e02 chore: remove test file 2025-11-09 17:04:19 +08:00
CrescentLeaf
5b425260c9 消除了两个空指针错误 2025-11-09 16:52:29 +08:00
CrescentLeaf
31133f5704 rename: docker-update 2025-11-09 16:43:14 +08:00
CrescentLeaf
93dad0b896 client: 自动进行重新验证 2025-11-09 16:42:44 +08:00
CrescentLeaf
8969fb7cb6 ui: 笼统的错误提示 2025-11-09 16:42:44 +08:00
CrescentLeaf
82c7c3772e ui: 当未登录时不会提示部分数据拉取错误 2025-11-09 16:42:43 +08:00
CrescentLeaf
df217b167e 加载消息和初次打开加载消息的页面置底优化? 2025-11-09 16:42:43 +08:00
CrescentLeaf
2f85aef136 chore: 删除调试代码 2025-11-09 16:42:43 +08:00
Tianpao
b4d63a709b fix: where is my "o" 2025-11-09 16:18:31 +08:00
Tianpao
f64349d802 feat: update.sh 2025-11-09 16:16:23 +08:00
CrescentLeaf
86ace28066 富文本消息显示大重构!!!
* 将所有的 custom element 以正确的方式重新编写
* 可以正确解析 Markdown 文本, 图片, 斜体文本元素且不会杂糅了
* 通过 DOM 操作使得所有的文本聚合在一起, 并且取消了消息自带的填充边距, 删除了原本消息内无法正常工作的 "无边框显示模式"
* 添加新的 custom-element: chat-text 和 chat-text-container
2025-11-09 16:06:24 +08:00
CrescentLeaf
b46449a6e4 fix: chat-video 没有更新 2025-11-09 16:03:29 +08:00
CrescentLeaf
19b2fce904 util: escapeHtml 2025-11-09 16:01:53 +08:00
CrescentLeaf
a7df2c689a ui: 移除对媒体文件的显示圆角, 并修正大小 (块级元素) 2025-11-09 16:01:38 +08:00
CrescentLeaf
6ce8acdb2e build: 取消丢弃 console 2025-11-09 12:46:38 +08:00
CrescentLeaf
149f003175 fix: typo 2025-11-09 10:39:06 +08:00
CrescentLeaf
f0ca0fbbd4 feat: 全新的客户端协议库! 2025-11-09 01:00:01 +08:00
CrescentLeaf
3e5fc722e6 fix: typo 2025-11-09 00:38:43 +08:00
CrescentLeaf
a646d7908a 添加两个客户端协议的类型文件 2025-11-09 00:33:01 +08:00
CrescentLeaf
cfe8df43d1 为 MessageBean 添加 chat_id 字段?
* 不知道有没有用, 有可能会被移除
* 有可能是史山
2025-11-09 00:32:05 +08:00
CrescentLeaf
743ccd1172 chore: 提取公共类 2025-11-08 23:50:43 +08:00
CrescentLeaf
3c5bd187b7 chore: 修缮非 throw 方法返回值 2025-11-08 23:49:58 +08:00
CrescentLeaf
27035eb2ca fix: 更新头像 file_hash 忘记检测 target 存在与否 2025-11-08 23:06:19 +08:00
CrescentLeaf
7a308e2261 fix: 客户端协议修改用户资料后没有在原对象 Bean 同步 2025-11-08 22:55:15 +08:00
CrescentLeaf
3cc60986ab fix: add missing import 2025-11-08 22:54:41 +08:00
CrescentLeaf
13edd23245 feat(protocol): register, refactor: authOrThrow 2025-11-08 18:03:19 +08:00
CrescentLeaf
bc386908f7 fix: typo 2025-11-08 18:03:19 +08:00
Tianpao
c1074d8a2c opti: 更小的前端打包体积 2025-11-08 17:50:20 +08:00
CrescentLeaf
6ee209f9f6 feat(wip): 新的客户端协议库 2025-11-08 16:17:58 +08:00
CrescentLeaf
230cc08182 fix&rename: 重命名中间件, 上传文件中间件没能执行下一个函数 2025-11-01 19:56:49 +08:00
CrescentLeaf
d60a11995e Merge branch 'main' of ssh://codeberg.org/CrescentLeaf/LingChair 2025-11-01 10:22:59 +08:00
CrescentLeaf
68886573a8 feat: 在对话信息页面收藏/取消收藏 2025-11-01 10:22:31 +08:00
CrescentLeaf
fabd325976 feat: 查看对话/用户的头像 2025-11-01 10:06:35 +08:00
Tianpao
3a56415968 fix: install error 2025-11-01 09:28:26 +08:00
Tianpao
ed5e962370 refactor: middleware 2025-11-01 04:19:17 +08:00
Tianpao
dd39c3e63c chore:docker yaml 2025-11-01 03:27:28 +08:00
CrescentLeaf
02485de52c ui: 调整消息媒体的大小 2025-11-01 01:20:30 +08:00
CrescentLeaf
8891cd23af fix: 文件最后使用时间 2025-11-01 01:13:33 +08:00
CrescentLeaf
661cebdb24 chore: 补充所需要的方法 2025-11-01 01:13:17 +08:00
CrescentLeaf
8b3b32422f refactor: 使用表单进行文件上传!
* 可以上传大文件啦
* 最大限制 2GB
* 后端方法重置
2025-11-01 01:12:50 +08:00
CrescentLeaf
dffa773acc feat: 可点击通知跳转对话 2025-11-01 01:11:44 +08:00
CrescentLeaf
51c6d1f0a6 feat(wip): serviceworker
* 用于缓存等
2025-11-01 01:08:52 +08:00
CrescentLeaf
bd35f5c3eb chore: 注释 2025-11-01 00:06:11 +08:00
CrescentLeaf
7409427ce5 chore: 修改 Socket.io Server 初始化参数 2025-10-31 21:56:51 +08:00
CrescentLeaf
7bc843d440 feat: 通过设置 token 到 Headers 获取文件 2025-10-31 21:55:54 +08:00
CrescentLeaf
6c1dd703bc chore: debug -> build-and-run-server, 修改 Docketfile 启动命令以自动构建前端 2025-10-31 21:34:12 +08:00
Tianpao
046831b4e5 chore:Dockerfile and yaml 2025-10-31 21:26:58 +08:00
CrescentLeaf
937af27698 feat: 删除收藏的对话 2025-10-26 23:04:12 +08:00
CrescentLeaf
5469ff6826 若factor: addContact -> s 2025-10-26 23:02:56 +08:00
CrescentLeaf
f8e6fcac46 chore: 客户端的 deno.jsonc: patch -> links
* 与此同时, 会导致旧版本 Deno 不认
2025-10-26 22:07:49 +08:00
CrescentLeaf
ab96ef889d fix: 当用户名没有被修改时, 忽略修改操作 2025-10-26 21:20:11 +08:00
CrescentLeaf
b1e618e07c feat: 修改密码 (UI) 2025-10-26 21:18:31 +08:00
CrescentLeaf
62ee2ef01f feat(wip): 帐号设定 2025-10-26 15:16:29 +08:00
CrescentLeaf
04125a1495 feat(wip): 重设密码 2025-10-26 15:16:20 +08:00
CrescentLeaf
110a90ed7a feat: 重连服务器提示 2025-10-26 15:13:40 +08:00
CrescentLeaf
bfc14777be feat: 检验是否能请求加入对话 2025-10-26 14:24:26 +08:00
CrescentLeaf
4e34e70a11 feat: 手动刷新对话页面 2025-10-26 14:24:06 +08:00
CrescentLeaf
2d2bc7be83 ui: 请求加入对话的组件状态跟随群组设定 2025-10-26 14:23:56 +08:00
CrescentLeaf
5d6c4d6660 fix: 错误的使用 admin 表名, 应为 getJoinRequestsTableName 2025-10-26 14:19:05 +08:00
CrescentLeaf
ab8895b008 fix: 退出登录失败 2025-10-26 14:04:07 +08:00
CrescentLeaf
d5e349ee88 feat: 通知 2025-10-25 01:23:41 +08:00
CrescentLeaf
760e5a118a refactor: 抽离出广播方法 2025-10-25 00:48:24 +08:00
CrescentLeaf
2d78e39ca1 fix: 添加了新的字段代替 chat id
* 谁又能想到 chat id 的可变性和依赖性恰恰埋下了祸患呢
2025-10-24 22:21:28 +08:00
CrescentLeaf
afd9193dea ui: 暂时隐藏未制作的功能 2025-10-24 22:01:17 +08:00
CrescentLeaf
bc7b932c5c feat: 修改对话 ID 对话名称 对话头像
* 仅群组
2025-10-24 22:00:22 +08:00
CrescentLeaf
4807038619 fix: Chat 中的列命名错误
*  avatar avatar_file_hash
2025-10-24 21:55:06 +08:00
CrescentLeaf
e18024b851 chore: 移除调试代码 2025-10-24 21:24:36 +08:00
CrescentLeaf
1dfe702c58 refactor: 对对话文件的真实地址获取重构
* 顺带引入了 tws://file?hash= 协议, 以后会填坑
2025-10-24 21:22:41 +08:00
CrescentLeaf
04a63ced87 ui: 修改 chat-file 的卡片样式 2025-10-24 21:21:33 +08:00
CrescentLeaf
50e3e21634 ui: 对富文本的纯文件消息进行显示优化
* 唉太难弄了, 那边距可太恐怖了
2025-10-24 21:21:12 +08:00
CrescentLeaf
5e5436b02c chore: make lint happy 2025-10-24 20:31:49 +08:00
CrescentLeaf
72016c5da1 refactor: avatar_file_hash instead of avatar 2025-10-24 20:29:51 +08:00
CrescentLeaf
bef6e88bf7 chore: make lint happy 2025-10-24 20:23:05 +08:00
CrescentLeaf
3789e476f7 chore: make lint happy 2025-10-24 20:22:18 +08:00
CrescentLeaf
ba71d66db8 feat: 加入对话请求 2025-10-19 18:23:46 +08:00
CrescentLeaf
af55143292 只有不是对话成员时才不会加载消息呢 2025-10-19 15:18:16 +08:00
CrescentLeaf
b824186c37 移除无用代码 2025-10-19 15:12:01 +08:00
CrescentLeaf
5034eb1da5 添加是否为成员和是否为管理员的字段 2025-10-19 15:11:40 +08:00
CrescentLeaf
5e44a273fc 添加初始化对话默认字段 2025-10-19 15:10:35 +08:00
CrescentLeaf
484381c6e5 local const 得到的 chatInfo 而不是使用旧的 state 2025-10-19 15:10:23 +08:00
CrescentLeaf
349e0933c3 移除 caused_by 史山 2025-10-19 15:09:41 +08:00
CrescentLeaf
08556c9d40 非对话管理员不得更改设定 2025-10-19 15:09:12 +08:00
CrescentLeaf
687bc7a9aa 加长 timeout 时间 2025-10-19 14:52:45 +08:00
CrescentLeaf
5a34054024 fix: 限制用户访问任意私聊 2025-10-19 11:55:57 +08:00
CrescentLeaf
306bfa2b82 refactor: ChatAdmins stored in Chat.db 2025-10-19 11:27:54 +08:00
CrescentLeaf
506790aefa remove: caused_by 字段 2025-10-19 11:27:24 +08:00
CrescentLeaf
ab1ef2c30b 修改配置失败时配置回退 2025-10-08 15:14:22 +08:00
CrescentLeaf
61bc1a265c fix: 由于系统消息没有发送者导致的 NOT NULL 错误 2025-10-08 15:13:56 +08:00
CrescentLeaf
9c45f3e13e 用户可以在不成为成员的情况下查看对话详情 2025-10-08 15:13:30 +08:00
CrescentLeaf
23ad29fb2d feat: 添加 caused_by 字段以便客户端知道是什么情况 2025-10-08 15:13:11 +08:00
CrescentLeaf
5b64c6adcf feat(wip): 入群需要审批 2025-10-08 15:12:46 +08:00
CrescentLeaf
dd42f5e54e fix: 不能正常显示系统消息 2025-10-08 15:00:31 +08:00
CrescentLeaf
2d7b7818d7 chore: 建群时添加一条系统消息 2025-10-08 14:56:51 +08:00
CrescentLeaf
c27eb37852 fix: wrong status code (400 -> 403) 2025-10-08 14:52:01 +08:00
CrescentLeaf
bc48cf801b fix: 创建群组时, 没有任何管理员 2025-10-08 14:49:42 +08:00
CrescentLeaf
e46661ba15 feat(wip): Chat admin 2025-10-08 14:47:27 +08:00
CrescentLeaf
9cb71af85b feat: get old value of preference when is was updated 2025-10-08 14:41:45 +08:00
CrescentLeaf
241ff714b8 fix: 对话页面 2025-10-08 12:25:33 +08:00
CrescentLeaf
db43de19c4 fix: 配置组件没有正确同步状态
* 问题出在我应该根据 State 决定组件状态而不是组件状态决定 State
* 踩坑了, 浪费我时间, 唉
2025-10-08 12:24:33 +08:00
CrescentLeaf
38c28c3fb6 我累了 2025-10-08 02:52:02 +08:00
CrescentLeaf
0df1149618 FEAT(灵车 WIP): CHAT SETTINGS 2025-10-08 02:51:58 +08:00
CrescentLeaf
aeafcb5b97 feat(WIP): 对话管理员 2025-10-08 02:51:25 +08:00
CrescentLeaf
324962b0fc refactor: 配置存储类泛型化 2025-10-08 02:50:58 +08:00
CrescentLeaf
f5f3774daf chore: 移除了 babel 编译流程
* 加快调试速度
* 旧版本浏览器本来也没办法支持了...
2025-10-08 02:50:31 +08:00
CrescentLeaf
e666dc573a chore: 移除分号 2025-10-08 01:12:05 +08:00
CrescentLeaf
11362a5689 chore: make lint happy 2025-10-08 00:55:09 +08:00
CrescentLeaf
7c7e641d1f fix: 遗漏的 return 2025-10-08 00:54:46 +08:00
CrescentLeaf
fabdd192dd rename: default(Value -> State)
* 我没有想到这是已有的属性定义
2025-10-07 23:07:27 +08:00
CrescentLeaf
8d7ddd46be chore: make Preferences' lint happy 2025-10-07 23:05:34 +08:00
CrescentLeaf
4b91bc9dbb feat(wip): 群组设定 2025-10-07 22:32:16 +08:00
CrescentLeaf
80c6f0b7a7 chore: 移除调试代码 2025-10-07 22:32:11 +08:00
CrescentLeaf
4eff829a30 feat: 配置页面组件 2025-10-07 22:31:34 +08:00
CrescentLeaf
96ca578c70 ui: 调整 mdui-menu 圆角大小 2025-10-07 14:53:01 +08:00
CrescentLeaf
7a0110180d ui: 加大 mdui-menu 圆角大小 2025-10-07 14:23:29 +08:00
CrescentLeaf
b36fe7a67e fix: 接口调用自动刷新访问令牌制造的的灵车
* 是什么我忘了, 但是这就是灵车😇
2025-10-07 13:07:11 +08:00
CrescentLeaf
6e73662860 docs: readme
唉, 我 width 怎么无效了 (
2025-10-06 12:22:22 +02:00
CrescentLeaf
318f75a7cc docs: readme 2025-10-06 18:20:39 +08:00
CrescentLeaf
bc5ed9e602 docs: readme 2025-10-06 18:19:33 +08:00
CrescentLeaf
8c8d17a1c7 docs: readme 2025-10-06 12:05:12 +02:00
CrescentLeaf
71dee043a3 docs: readme 2025-10-06 17:48:57 +08:00
CrescentLeaf
059078ea8f refactor: 忽略刷新访问令牌重试的请求 方法重写
* 将原有的 CallableMethodBeforeAuth 纳入
2025-10-06 17:31:35 +08:00
CrescentLeaf
674fe000f4 fix: 无限进行刷新访问令牌
* 由于 Client.ts 中的 invoke 没有对请求方法做判断, 导致不该被 retry 的请求被自动重试
2025-10-06 17:27:03 +08:00
CrescentLeaf
85477fe46e feat: 添加刷新令牌支持
* 服务端: 添加对应的接口, 对原有令牌系统稍有修改, 添加了令牌类型
* 客户端: 自动刷新访问令牌, 登录时顺带获取刷新令牌
2025-10-06 17:13:23 +08:00
CrescentLeaf
dced175d7a chore: 统一为简体中文 2025-10-06 15:36:12 +08:00
CrescentLeaf
bd857b840b chore: 修改网页标题 2025-10-06 14:52:48 +08:00
CrescentLeaf
5d1c395340 docs: readme 2025-10-06 14:46:13 +08:00
CrescentLeaf
0e17b37156 chore: add icon for this project 2025-10-06 14:42:38 +08:00
CrescentLeaf
fb48c44655 ui: 移除 添加对话 输入框边距 2025-10-06 02:13:25 +08:00
CrescentLeaf
7378024235 feat: 添加任意对话, chore: 使用 User.create (createWithUserNameChecked 已移除) 2025-10-06 02:11:41 +08:00
CrescentLeaf
1c985f28a2 feat: 自动检验用户名和对话 ID 是否已经存在 2025-10-06 02:10:51 +08:00
CrescentLeaf
449c0a8806 feat: 创建群组, impl - 获取群组信息 2025-10-06 02:09:30 +08:00
CrescentLeaf
e1e42ea188 feat: 添加任意对话, 不局限于用户 2025-10-06 02:09:03 +08:00
CrescentLeaf
823eef76b0 feat: 所有对话列表中, 创建群组 2025-10-06 02:08:14 +08:00
CrescentLeaf
3b0b5ff032 feat: 创建群组对话框 2025-10-06 02:07:25 +08:00
CrescentLeaf
6112b4b207 fix: https cannot load pem 2025-10-06 00:56:42 +08:00
CrescentLeaf
9e8e967eb9 chore: remove useless code 2025-10-04 22:18:58 +08:00
CrescentLeaf
697082193f fix: missing contact type of contactsList 2025-10-04 22:18:58 +08:00
CrescentLeaf
86d68fd5e5 feat(wip): 群组 2025-10-04 22:13:13 +08:00
CrescentLeaf
ffa8ac73de ui: 微调 RecentsListItem 文字位置 2025-10-04 21:29:41 +08:00
CrescentLeaf
f01f3b02f4 ui: 移除延迟设置应用视图大小 2025-10-04 16:02:43 +08:00
CrescentLeaf
ad4e873d2f ui: 用户资料中进入对话, 连带上层对话框关闭 2025-10-04 15:52:22 +08:00
CrescentLeaf
a77e22a3ea feat: 从对话详情打开用户详情 2025-10-04 15:49:19 +08:00
CrescentLeaf
1fa91279e2 fix: 阻止用户头像点击事件传播 2025-10-04 15:35:16 +08:00
CrescentLeaf
debdb93935 feat: 对话中打开用户的资料 2025-10-04 15:32:54 +08:00
CrescentLeaf
81cdb4afd9 feat: Chat.getIdForPrivate 2025-10-04 15:32:29 +08:00
CrescentLeaf
bc08cd3c8c feat: 直接和对方私聊 2025-10-04 15:32:11 +08:00
CrescentLeaf
c24078b29d fix: stupid myId instead of targetUserId 2025-10-04 15:31:22 +08:00
CrescentLeaf
f04748aa5c feat: 在对话中打开对话详情对话框 2025-10-04 14:56:00 +08:00
CrescentLeaf
5ce97283f1 refactor: 抽离 openChatInfoDialog 2025-10-04 14:55:24 +08:00
CrescentLeaf
d6f794a094 fix: Chat.getInfo should return id 2025-10-04 14:55:06 +08:00
CrescentLeaf
47bbf12176 ui: 移动端设备上, 最近列表不会呈现激活状态 2025-10-04 14:40:30 +08:00
CrescentLeaf
2cee988ada feat: 自动更新最近对话
* 接收新消息
* 定时 15s
2025-10-04 14:35:19 +08:00
CrescentLeaf
04989762d9 feat: 最近对话 2025-10-04 14:32:22 +08:00
CrescentLeaf
89db6591a0 feat(api): User.getMyRecentChats 2025-10-04 14:13:06 +08:00
CrescentLeaf
d173fb7842 向消息接收者添加最近对话 2025-10-04 14:12:51 +08:00
CrescentLeaf
4133c13cf8 feat: RecentChats in User & Bean 2025-10-04 14:12:07 +08:00
CrescentLeaf
a1eddf813d chore: declare User.getMyRecentChats 2025-10-04 14:11:35 +08:00
CrescentLeaf
39b4a6d8a6 chore: 添加 Map 序列化相关辅助类 2025-10-04 14:07:21 +08:00
CrescentLeaf
e4cf9d6a68 chore: add reference 2025-10-04 13:02:29 +08:00
CrescentLeaf
f29538762b chore: 添加 Map 序列化相关辅助类 2025-10-04 13:02:13 +08:00
CrescentLeaf
7616a49ff8 chore: Chat (client) 同步伺服器端 Bean 定義 2025-10-04 12:10:32 +08:00
CrescentLeaf
42aefdd2f1 chore: deno ignore ./client/mdui_patched 2025-10-04 12:07:28 +08:00
CrescentLeaf
5fadb76a20 fix: 用戶現在無法任意訪問其他不屬於他的對話 2025-10-04 11:52:34 +08:00
CrescentLeaf
5474eac554 chore: 复制文字 先 trim 一遍 2025-10-04 11:42:03 +08:00
CrescentLeaf
a12a8830d4 ui: 改善 复制到剪贴薄 的用户体验
* 在 Via 浏览器上, writeText 本质上被重写了, 逻辑还是 execCommand copy
* 更换 copy 为 cut
2025-10-04 11:34:56 +08:00
CrescentLeaf
6c5f3aac85 ui: 修正由于 Menu 关闭没有同步状态导致的开关不正常 2025-10-04 11:07:55 +08:00
CrescentLeaf
6e164cbdfb fix: 本地 patch MDUI 以解决 tabindex = 0 导致的一系列玄学问题 2025-10-04 11:07:03 +08:00
CrescentLeaf
af694f6f6c ui: fallback to window.inner*
* Android 上, avail* 还是屏幕的
2025-10-03 23:58:16 +08:00
CrescentLeaf
c5ce13b13c chore: 測試兼容更舊的瀏覽器内核
* 效果不咋地, Edge 84 因爲 Marked.js 炸了
2025-10-03 23:33:06 +08:00
CrescentLeaf
0026cae639 ui: 繼續修繕 onResize 邏輯
* Edge 84, 但是廢了
* 實際上, avail* 不准, 但是並不知道什麽情況下才會
2025-10-03 23:07:21 +08:00
CrescentLeaf
376177d78e rename: (User -> My)ProfileDialog 2025-10-03 12:49:28 +08:00
CrescentLeaf
6c9ee005fd fix: 橫豎屏切換 resize 時的大小不當
* 橫屏時, 測試 Via 瀏覽器時 可能是因為全屏不當, 大小不正確, 也因此需要手動縮小, 繼續切豎屏正常
2025-10-03 12:47:19 +08:00
CrescentLeaf
82c5aeaaa0 ui: 修正 文件卡片 文字折行失敗
* https://commandnotfound.cn/css-layout/101/644/CSS-控制长文本自动折行
* 也許是 width 相關導致word-wrap 沒效果?
* 玄學
2025-10-03 01:08:57 +08:00
CrescentLeaf
14c279cc80 ui: 點擊聊天文件不會再跳轉的同時並下載了, 只會進行下載 2025-10-02 23:38:43 +08:00
CrescentLeaf
67c6f11892 ui: 微調 置底 延遲時間 2025-10-02 23:35:08 +08:00
CrescentLeaf
edf35b7dd0 ui: 阻止 附件 點擊導致消息菜單彈出 2025-10-02 21:28:11 +08:00
CrescentLeaf
de886dcfcc ui: 修正 chat-file 為 超鏈接 2025-10-02 21:27:52 +08:00
CrescentLeaf
67f019713a ui: 修正 消息右鍵菜單
* 修正打開狀態
* 避免不必要的狀態變更
2025-10-02 20:54:39 +08:00
CrescentLeaf
2af396a2b8 chore: 修正一個不合理的 uri-list 轉換成功判斷 2025-10-02 20:51:07 +08:00
CrescentLeaf
20f12c97c1 ui: 微調 時間顯示 文字 2025-10-02 20:49:00 +08:00
CrescentLeaf
15c4bcd48e fix: 消息發送失敗時, 沒有取消 加載狀態 2025-10-02 18:49:19 +08:00
CrescentLeaf
e429bbbcdb feat: 瀏覽器下載文件時, 支持設定爲原始文件名
* 基於 Content-Disposition
2025-10-02 18:42:29 +08:00
CrescentLeaf
020fd63c97 feat: 聊天文件 2025-10-02 18:37:25 +08:00
CrescentLeaf
2771503b6f fix: typo 2025-10-02 18:30:58 +08:00
CrescentLeaf
65458cf491 chore: make lint happy 2025-10-02 18:18:29 +08:00
CrescentLeaf
c23fdbf310 feat: 聊天視頻 (初始化) 2025-10-02 11:14:39 +08:00
CrescentLeaf
dfeed305e1 feat: 支持對話視頻, wip: 文件 2025-10-02 11:14:07 +08:00
CrescentLeaf
bc5485d622 移除 剪貼薄 fallback 元素 2025-10-02 11:12:07 +08:00
CrescentLeaf
d3b2949ff7 剪貼薄 2025-10-02 10:49:36 +08:00
CrescentLeaf
f376de2b48 ui: 微調消息菜單
* 仿照電報邏輯
* 添加 JsonView
2025-10-01 11:51:28 +08:00
CrescentLeaf
459fca064c depend: add react-json-view@1.21.3 2025-10-01 11:23:09 +08:00
CrescentLeaf
f436f84696 feat(wip): 支持視頻和文件 2025-10-01 01:08:52 +08:00
CrescentLeaf
73e795e29f fix: Marked 不解析 \n, 手動解析以使換行符正常使用 2025-10-01 01:03:39 +08:00
CrescentLeaf
a709ac7ee0 ui: 修繕圖片顯示, 不再為下方文字説明占位 (aka inline -> block) 2025-10-01 01:02:49 +08:00
CrescentLeaf
beab35a25e chore: make lint happy 2025-10-01 00:57:34 +08:00
CrescentLeaf
aa0d0e86a5 docs: readme 2025-10-01 00:36:41 +08:00
CrescentLeaf
f8a043e59f docs: readme 2025-10-01 00:20:16 +08:00
CrescentLeaf
441fb5b5be docs: license 2025-10-01 00:20:11 +08:00
CrescentLeaf
29ea6f9142 chore: 迁移工具方法 isMobileUI 2025-10-01 00:02:20 +08:00
CrescentLeaf
cd22f62d60 fix(wip): 复制文字到剪贴板 2025-10-01 00:01:52 +08:00
CrescentLeaf
b3ffdf8469 feat: 消息菜单 2025-10-01 00:01:39 +08:00
CrescentLeaf
6c17a0e4eb feat: 显示消息时间 2025-10-01 00:01:27 +08:00
CrescentLeaf
8163100559 feat: 上报消息时间戳 2025-09-30 23:47:49 +08:00
CrescentLeaf
1fec2bba06 feat(wip): 顯示消息的時間 2025-09-30 21:56:18 +08:00
CrescentLeaf
706a340407 fix: 在沒有消息時, 發送消息並拉取導致的消息重複 2025-09-30 21:54:25 +08:00
CrescentLeaf
7e81484932 refactor: 獨立 openImageViewer 2025-09-30 21:37:31 +08:00
CrescentLeaf
d7d8351dc9 ui: 添加細節: 添加聯絡人可直接回車, 可直接點擊清空 2025-09-30 21:35:58 +08:00
CrescentLeaf
19657fd150 ui: 微調: 可以點擊外部關閉對話框 2025-09-25 17:26:57 +08:00
CrescentLeaf
a6cef76ecf feat: 手動刷新聯絡人列表 2025-09-25 17:26:44 +08:00
CrescentLeaf
ee8e0e531e fix: 對話中的成員 無法收到更新信息 2025-09-25 17:16:20 +08:00
CrescentLeaf
151dc31f2c chore: 規範化 client event listener 寫法 2025-09-25 17:14:37 +08:00
CrescentLeaf
0b1a4a53a5 chore: make lint happy 2025-09-25 17:14:09 +08:00
CrescentLeaf
02efac9a8e fix: 用戶添加自己為對話或重複導致的重複 2025-09-25 16:52:51 +08:00
CrescentLeaf
8d739dd863 chore: 添加 EventBus
* 並讓對話列表先用上了
2025-09-25 16:52:23 +08:00
CrescentLeaf
c0c6c6ed1c feat: 添加對話 2025-09-25 16:51:43 +08:00
CrescentLeaf
d26c67f06d fix: 無法正常在 private chat 獲取到對方 User 2025-09-25 16:48:06 +08:00
CrescentLeaf
35d60642c0 chore: 生成的 Private chat id 人類可讀 2025-09-25 16:40:30 +08:00
CrescentLeaf
a928577f2a fix: 打開不同對話時, 使用了同一個 ChatFragment
* 並修復了使用 key 時, 因爲卸載組件后 ref 丟失導致的錯誤
2025-09-25 16:26:46 +08:00
CrescentLeaf
4b93e5fd67 chore: deno task server 不再自動編譯前端
* 請使用 debug 或 手動 build
2025-09-25 15:04:50 +08:00
CrescentLeaf
d6454f51c8 feat: find user by account (aka userName or userId) 2025-09-25 14:53:53 +08:00
CrescentLeaf
efc0f49b66 feat: 文件權限檢驗
* 基於讀取 Cookie 中的驗證信息
* 因為 ServiceWorker 需要安全的上下文, 而我想要到處可用, 因此暫時折中使用這個辦法
2025-09-25 14:19:45 +08:00
CrescentLeaf
a860da96a0 depend: add cookie-parser 1.4.7 2025-09-25 14:19:18 +08:00
CrescentLeaf
692eb3d2a3 chore: 將令牌檢測函數移動到 TokenManager
* 這樣才叫 TokenManager 嘛X
2025-09-25 14:18:50 +08:00
CrescentLeaf
b6be09ef7c chore: 客戶端自動添加 token 和 device_id 到 Cookie 裡, 以便 HTTP 請求 2025-09-25 14:17:29 +08:00
CrescentLeaf
8e15c8126f chore(wip): 聯絡人 -> 對話
* 這是設計時留下的問題, 現在逐步改正
2025-09-25 13:02:37 +08:00
CrescentLeaf
80a42d5d86 feat(wip): 添加對話 對話框 2025-09-25 13:02:02 +08:00
CrescentLeaf
b8f3886a1b chore: fuck lint and make it happy 2025-09-25 12:57:08 +08:00
CrescentLeaf
5a80041ec3 ui: 微調對話框選項距離 2025-09-25 12:54:42 +08:00
CrescentLeaf
d76e7e2bf5 chore: make lint happy & fix typo 2025-09-25 12:53:07 +08:00
CrescentLeaf
4fa3e16ab7 fix: 令牌驗證額外添加是否為有效令牌
* 如果解密無效, 直接返回一個無效的令牌, 並加以判斷
2025-09-25 12:12:12 +08:00
CrescentLeaf
9cc3a2149e feat: 退出登錄 2025-09-25 12:12:04 +08:00
CrescentLeaf
fdf52c0548 feat(wip): 複製到剪貼薄 2025-09-25 11:36:03 +08:00
CrescentLeaf
a6ee231ad5 feat: 客戶端查看自己的用戶 ID 2025-09-25 11:35:35 +08:00
CrescentLeaf
4bcc6e4347 feat: 手動選擇文件 2025-09-25 00:42:31 +08:00
CrescentLeaf
9395104c20 ui: 在加載歷史消息時,自動回到加載前的消息位置
* 使用奇技淫巧
2025-09-25 00:31:40 +08:00
CrescentLeaf
f063c4d165 ui: 修復錯誤添加在 最近對話 搜索框 的 paddingRight 2025-09-24 23:43:24 +08:00
CrescentLeaf
b3b077fa9d ui: 移動端支持修改個人資料, 修繕移動端 UI 的諸多潛在問題 2025-09-24 23:41:11 +08:00
CrescentLeaf
88123e1edb ui: 修正 最近對話 列表的 paddingLeft 2025-09-24 23:39:53 +08:00
CrescentLeaf
0106311a2a chore: make lint happy 2025-09-24 23:09:55 +08:00
CrescentLeaf
f5f2d5743f fix: typo 2025-09-24 23:09:20 +08:00
CrescentLeaf
4e38ad8e20 fix: 移動端未打開對話但提示對話不存在 2025-09-24 23:01:07 +08:00
CrescentLeaf
41362a591c feat: 粘貼文件, 多個同名文件共存發送 2025-09-24 22:57:12 +08:00
CrescentLeaf
1b36a45252 ui: 修繕圖片縮放對話框: 圖片原始位置
* 我盡力了, 這玩意設置位置太靈車了
2025-09-24 22:32:15 +08:00
CrescentLeaf
38db2e1310 fix: 多個同 DeviceId 不同 Session 的客戶端無法同時收到消息 2025-09-24 22:03:23 +08:00
CrescentLeaf
9a3e87d89c chore: 客戶端發送非驗證性請求前, 必須先等待驗證 2025-09-24 21:52:53 +08:00
CrescentLeaf
954b5d3430 ui: 細節優化: 發送消息時, 轉圈 2025-09-24 21:44:52 +08:00
CrescentLeaf
6dfe59c5a8 chore(ui): 圖片加載失敗使用 snackbar 提示 2025-09-24 21:34:04 +08:00
CrescentLeaf
b741cbf9ba chore: 進一步解除傳輸最大限制 2025-09-24 21:33:30 +08:00
CrescentLeaf
d5fbc490ea feat: 支持發送文件
* 目前還只能拖拽到輸入框
2025-09-24 21:33:16 +08:00
CrescentLeaf
276ce5cae8 fix: 控制臺不解析 buffer 2025-09-24 21:32:09 +08:00
CrescentLeaf
3a9312654e chore: 控制臺不解析 buffer
* 額外作用: 加快傳輸效率
2025-09-24 21:19:42 +08:00
CrescentLeaf
0a10009613 chore: localify pinch-zoom 2025-09-24 18:59:25 +08:00
CrescentLeaf
8759b660f5 refactor: randomUUID with fallback 2025-09-24 18:15:41 +08:00
CrescentLeaf
ae3b9c8226 ui: 修正 Tab 指示標顯示不正常 2025-09-24 18:15:04 +08:00
CrescentLeaf
faec599822 feat(wip): 富文本消息 (aka Markdown + 自定義解析) 2025-09-24 16:43:28 +08:00
CrescentLeaf
da1c7cd8cf fix: Markdown 沒有被渲染 2025-09-24 10:57:21 +08:00
CrescentLeaf
a0bf323ac9 feat(wip): Markdown 2025-09-24 09:23:31 +08:00
CrescentLeaf
4a2014e10d feat(wip): 上傳文件 2025-09-23 23:29:20 +08:00
CrescentLeaf
a01a64116f feat(wip): markdown 解析 2025-09-23 23:28:54 +08:00
CrescentLeaf
f6f2590532 chore: make lint happy 2025-09-23 23:10:04 +08:00
CrescentLeaf
20f5484e90 feat: 支持異步接口調用方法體 2025-09-23 23:08:50 +08:00
CrescentLeaf
14f5bbfec9 feat: 適配移動端界面 2025-09-23 17:44:10 +08:00
CrescentLeaf
5d5b04ba05 refactor: 重構 對話 成員的儲存邏輯
* 使用關聯資料庫, 鏈接 user_id 和 chat_id
2025-09-23 09:20:30 +08:00
CrescentLeaf
0ef2859291 feat(wip): 上傳圖片等多媒體文件 2025-09-23 00:27:57 +08:00
CrescentLeaf
b82d32cad7 chore: 添加 Chat 類型的常量定義 2025-09-22 23:08:41 +08:00
CrescentLeaf
10da3b8e77 refactor: 重寫 Chat 成員邏輯
* 不再區分 user_a/b, 直接使用 members_list 雙成員模式
* 爲以後群聊打下基礎
2025-09-22 23:08:19 +08:00
CrescentLeaf
184a80436d feat: 自動在重連時進行身份驗證 2025-09-22 23:06:24 +08:00
CrescentLeaf
2de4d3548d feat: 視情況 自動滾動到最新消息 2025-09-22 23:06:01 +08:00
CrescentLeaf
fc197ea41a feat(ui): 拉到最頂部加載更多消息 2025-09-22 23:05:21 +08:00
CrescentLeaf
43385780f8 fix: 打開對話后不會自己滑動到底部 2025-09-22 19:39:53 +08:00
CrescentLeaf
791102c034 fix: MessageManager 建表失敗 2025-09-21 16:13:48 +08:00
CrescentLeaf
8bcb3e74b6 feat: 服務端可以獲取每個客戶端的連接 2025-09-21 16:13:31 +08:00
CrescentLeaf
e4c26a07cf feat: 緩存資料, 獲取任意用戶的資料 2025-09-21 16:13:01 +08:00
CrescentLeaf
f118c6b6f5 chore: 修繕客戶端請求允許等待連接 2025-09-21 16:12:31 +08:00
CrescentLeaf
cb947429fb feat: 收發消息 2025-09-21 16:11:58 +08:00
CrescentLeaf
b3d620a329 refactor: 重寫消息顯示相關邏輯 2025-09-21 16:11:13 +08:00
CrescentLeaf
28ffd134df feat: 服務端 Api 可以持有 client socket 2025-09-21 14:12:06 +08:00
CrescentLeaf
f600245d3b feat: BaseApi 有條件獲取更多的數據 2025-09-21 14:06:36 +08:00
CrescentLeaf
706d811087 feat(wip): 事件緩存以備離綫重連重發 2025-09-21 14:06:08 +08:00
CrescentLeaf
e5dd3ade51 feat: 檢驗用戶的 設備 ID 2025-09-21 12:28:44 +08:00
CrescentLeaf
83719f5f44 fix(ui): 兩個列表沒有吃滿寬度 2025-09-21 11:06:21 +08:00
CrescentLeaf
082817d6cd feat(wip): 收發消息 2025-09-21 02:18:15 +08:00
CrescentLeaf
ee79e3eefa chore: RecentChat -> extends <- Chat 2025-09-21 02:18:05 +08:00
CrescentLeaf
6a1084eeca fix: Chat 創建失敗, 並修正了 ChatPrivate 獲取對方的邏輯 2025-09-21 02:17:44 +08:00
CrescentLeaf
71e6d24d6e fix: Chat 獲取 avatar 邏輯錯誤 2025-09-21 02:16:48 +08:00
CrescentLeaf
7686a9b7d1 chore: 使 Client.invoke 支持等待后請求, 解耦 updateCachedProfile 2025-09-21 02:16:01 +08:00
CrescentLeaf
4837c17c2e fix: Chat (客戶端側) title 設置為非空 2025-09-21 02:15:27 +08:00
CrescentLeaf
3d367711cc feat(wip): 聯絡人/群組對話框, 並打開對應的對話 2025-09-21 02:14:39 +08:00
CrescentLeaf
6f006f38a4 fix: app.use -> get 2025-09-21 02:13:55 +08:00
CrescentLeaf
cb4aeaed21 fix(ui): Avatar 不顯示文字 2025-09-21 02:13:28 +08:00
CrescentLeaf
791baf474c feat: 修復並正式支持聯絡人
* wip(ui): 增刪
2025-09-21 02:13:16 +08:00
CrescentLeaf
468de4f439 feat(ui): 編輯個人檔案對話框 2025-09-21 02:11:47 +08:00
CrescentLeaf
2ec4f634ae feat(wip): remove contact 2025-09-20 21:17:43 +08:00
CrescentLeaf
8f7e61dfd2 feat: Chat (instance) getAnotherUserForPrivate 2025-09-20 20:59:12 +08:00
CrescentLeaf
212c2fa5dc chore: 重命名易混淆的 ChatPrivate findFor 方法 2025-09-20 20:58:44 +08:00
CrescentLeaf
dd88e8d1b8 chore: 添加 ChatApi 注釋 2025-09-20 20:58:20 +08:00
615 changed files with 99397 additions and 1720 deletions

6
.gitignore vendored
View File

@@ -2,6 +2,6 @@
thewhitesilk_config.json
# **默认**数据目录
thewhitesilk_data/
deno.lock
node_modules/
# Node.js
package-lock.json
node_modules/

8
.vscode/launch.json vendored
View File

@@ -5,10 +5,10 @@
"version": "0.2.0",
"configurations": [
{
"command": "deno task debug",
"name": "Run debug",
"command": "npm run debug",
"name": "Debug",
"request": "launch",
"type": "node-terminal",
},
"type": "node-terminal"
}
]
}

View File

@@ -1,6 +0,0 @@
{
"deno.enable": true,
"deno.disablePaths": [
"./thewhitesilk_data"
]
}

22
Dockerfile Normal file
View 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"]

View File

@@ -0,0 +1,3 @@
import { ApiCallbackMessage } from 'lingchair-internal-shared'
export type { ApiCallbackMessage as default }

View 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

View File

@@ -0,0 +1,8 @@
import LingChairClient from "./LingChairClient.ts"
export default class BaseClientObject {
declare client: LingChairClient
constructor(client: LingChairClient) {
this.client = client
}
}

View 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
View 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
}
}

View 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
}
}

View 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
View 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
}
}

View 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
View 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
}
}

View 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))
}
}

View File

@@ -0,0 +1,5 @@
interface BaseChatSettings {
[key: string]: unknown
}
export default BaseChatSettings

View 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
}

View 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

View 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
}

View 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
}

View File

@@ -0,0 +1,5 @@
import ChatBean from "./ChatBean.ts"
export default class RecentChatBean extends ChatBean {
declare content: string
}

View File

@@ -1,6 +1,6 @@
export default class User {
export default class UserBean {
declare id: string
declare username?: string
declare nickname: string
declare avatar?: string
}
declare avatar_file_hash?: string
}

28
client-protocol/main.ts Normal file
View 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 }

View 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"
}
}

View File

@@ -0,0 +1,3 @@
type ChatType = 'private' | 'group'
export default ChatType

View File

@@ -0,0 +1,3 @@
type JoinRequestAction = 'accept' | 'remove'
export default JoinRequestAction

View 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
View 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]
}
}

View File

@@ -1,48 +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
}
}
}
// 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

View File

@@ -1,16 +0,0 @@
export type CallMethod =
"User.auth" |
"User.register" |
"User.login" |
"User.setNickName" |
"User.setUserName" |
"User.setAvatar" |
"User.getMyInfo" |
"Chat.getInfo" |
"Chat.sendMessage" |
"Chat.getMessageHistory"
export type ClientEvent =
"Client.onMessage"

View File

@@ -1,55 +0,0 @@
import { io, Socket } from 'socket.io-client'
import { CallMethod, ClientEvent } from './ApiDeclare.ts'
import ApiCallbackMessage from './ApiCallbackMessage.ts'
import User from "./client_data/User.ts"
type UnknownObject = { [key: string]: unknown }
class Client {
static myUserProfile?: User
static socket?: Socket
static events: { [key: string]: (data: UnknownObject) => UnknownObject } = {}
static connect() {
this.socket?.disconnect()
this.socket && delete this.socket
this.socket = io({
transports: ['websocket']
})
this.socket!.on("The_White_Silk", (name: string, data: UnknownObject, callback: (ret: UnknownObject) => void) => {
try {
if (name == null || data == null) return
const re = this.events[name]?.(data)
re && callback(re)
} catch (e) {
console.error(e)
}
})
}
static invoke(method: CallMethod, args: UnknownObject = {}, timeout: number = 5000): Promise<ApiCallbackMessage> {
if (this.socket == null) throw new Error("客戶端未與伺服器端建立連接!")
return new Promise((resolve, reject) => {
this.socket!.timeout(timeout).emit("The_White_Silk", method, args, (err: string, res: ApiCallbackMessage) => {
if (err) return reject(err)
resolve(res)
})
})
}
static async auth(token: string, timeout: number = 5000) {
const re = await this.invoke("User.auth", {
access_token: token
}, timeout)
if (re.code == 200)
this.myUserProfile = (await Client.invoke("User.getMyInfo", {
token: token
})).data as unknown as User
return re
}
static on(eventName: ClientEvent, func: (data: UnknownObject) => UnknownObject) {
this.events[eventName] = func
}
static off(eventName: ClientEvent) {
delete this.events[eventName]
}
}
export default Client

View File

@@ -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
}

View File

@@ -1,5 +0,0 @@
export default class Message {
declare id: number
declare text: string
declare user_id: string
}

View File

@@ -1,6 +0,0 @@
export default class RecentChat {
declare id: string
declare title: string
declare avatar?: string
declare content: string
}

View File

@@ -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
View 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

View File

@@ -1,30 +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",
"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"
}
}

8
client/env.d.ts vendored Normal file
View 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

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -10,24 +10,15 @@
<link rel="icon" href="icon.ico" />
<link rel="stylesheet" href="./static/material_icons.css" />
<title>TheWhiteSilk</title>
<title>LingChair</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app"></div>
<mdui-snackbar close-on-outside-click id="public_snackbar"></mdui-snackbar>
<mdui-dialog close-on-overlay-click id="ErrorDialog">
<span slot="headline">错误</span>
<span slot="description" id="ErrorDialog_Message"></span>
</mdui-dialog>
<script nomodule>
alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(')
</script>
<script type="module" src="./index.ts"></script>
<script type="module" src="./init.ts"></script>
</body>
</html>

View File

@@ -1,33 +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'
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'
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(urlParams.get('mobile') == 'true' ? AppMobile : App, null))
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()

41
client/init.ts Normal file
View 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
View 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
View 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()
}

View File

@@ -67,3 +67,7 @@ html {
.gutter.gutter-horizontal {
cursor: col-resize;
}
a {
color: rgb(var(--mdui-color-primary));
}

View File

@@ -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
View 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
}
}

View File

@@ -1,194 +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 UserProfileDialog from "./dialog/UserProfileDialog.tsx"
import ContactsList from "./main/ContactsList.tsx"
import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts"
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
slot?: string
}
}
}
}
export default function App() {
const [recentsList, setRecentsList] = React.useState([
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "麻油衣酱",
content: "成步堂君, 我又坐牢了("
},
{
id: '1',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "Maya Fey",
content: "我是绫里真宵, 是一名灵媒师~"
},
] as RecentChat[])
const [contactsList, setContactsList] = React.useState([
{
id: '1',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "麻油衣酱",
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "Maya Fey",
},
] as User[])
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 userProfileDialogRef = React.useRef<Dialog>(null)
const openMyUserProfileDialogButtonRef = React.useRef<HTMLElement>(null)
useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => {
userProfileDialogRef.current!.open = true
})
const [myUserProfileCache, setMyUserProfileCache]: [User, React.Dispatch<React.SetStateAction<User>>] = 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} />
<UserProfileDialog
userProfileDialogRef={userProfileDialogRef as any}
user={myUserProfileCache} />
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon slot="top">
<Avatar src={myUserProfileCache?.avatar} text={myUserProfileCache?.nickname} avatarRef={openMyUserProfileDialogButtonRef} />
</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="contacts--outlined" active-icon="contacts--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
openChatFragment={(id) => {
setIsShowChatFragment(true)
}}
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} />
}
</div>
</div>
)
}

View File

@@ -1,145 +0,0 @@
import Client from "../api/Client.ts"
import data from "../Data.ts"
import useEventListener from './useEventListener.ts'
import User from "../api/client_data/User.ts"
import RecentChat from "../api/client_data/RecentChat.ts"
import * as React from 'react'
import { 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 UserProfileDialog from "./dialog/UserProfileDialog.tsx"
import ContactsList from "./main/ContactsList.tsx"
import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts"
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
slot?: string
}
}
}
}
export default function AppMobile() {
const [recentsList, setRecentsList] = React.useState([
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "麻油衣酱",
content: "成步堂君, 我又坐牢了("
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "Maya Fey",
content: "我是绫里真宵, 是一名灵媒师~"
},
] as RecentChat[])
const [contactsMap, setContactsMap] = React.useState({
: [
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "麻油衣酱",
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "Maya Fey",
},
],
} as unknown as { [key: string]: User[] })
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationBarRef: React.MutableRefObject<NavigationBar | null> = React.useRef(null)
useEventListener(navigationBarRef, 'change', (event) => {
setNavigationItemSelected((event.target as HTMLElement as NavigationBar).value as string)
})
const loginDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
const loginInputAccountRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const loginInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
const registerInputUserNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerInputNickNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const userProfileDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
const openMyUserProfileDialogButtonRef: React.MutableRefObject<HTMLElement | null> = React.useRef(null)
/* useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => {
userProfileDialogRef.current!.open = true
})*/
const [myUserProfileCache, setMyUserProfileCache]: [User, React.Dispatch<React.SetStateAction<User>>] = React.useState(null as unknown as User)
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',
width: 'var(--whitesilk-window-width)',
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} />
<UserProfileDialog
userProfileDialogRef={userProfileDialogRef}
user={myUserProfileCache} />
<mdui-navigation-bar scroll-target="#SideBar" label-visibility="selected" value="Recents" ref={navigationBarRef}>
<mdui-navigation-bar-item icon="watch_later--outlined" value="Recents"></mdui-navigation-bar-item>
<mdui-navigation-bar-item icon="contacts--outlined" value="Contacts"></mdui-navigation-bar-item>
</mdui-navigation-bar>
<div style={{
display: 'flex',
height: 'calc(100% - 80px)',
width: '100%',
}} id="SideBar">
{
// 最近聊天
<RecentsList
display={navigationItemSelected == "Recents"}
recentsList={recentsList} />
}
{
// 联系人列表
<ContactsList
display={navigationItemSelected == "Contacts"}
contactsMap={contactsMap} />
}
</div>
</div>
)
}

View File

@@ -12,12 +12,12 @@ export default function Avatar({
avatarRef,
...props
}: Args) {
if (src != null)
if (src != null && src != '')
return <mdui-avatar ref={avatarRef} {...props} src={src} />
else if (text != null)
else if (text != null && text != '')
return <mdui-avatar ref={avatarRef} {...props}>
{
text.substring(0, 0)
text.substring(0, 1)
}
</mdui-avatar>
else

View 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
View 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
View 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>
)
}

View 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 }

View 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()
}
})

View 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()
}
})

View 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: "该提及没有指定用户或者对话!",
})
}
}
}
})

View 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()
}
})

View 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
}
})

View 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' : ''
}
})

View 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()
}
})

View File

@@ -1,110 +0,0 @@
import { Tab } from "mdui"
import useEventListener from "../useEventListener.ts"
import Element_Message from "./Message.jsx"
import MessageContainer from "./MessageContainer.jsx"
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 } from "../snackbar.ts"
import useAsyncEffect from "../useAsyncEffect.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
target: string,
}
export default function ChatFragment({ target, ...props }: Args) {
const [messagesList, setMessagesList] = React.useState([] as Message[])
const [chatInfo, setChatInfo] = React.useState({
title: '加載中...'
} as Chat)
const [tabItemSelected, setTabItemSelected] = React.useState('Chat')
const tabRef = React.useRef<Tab>(null)
useEventListener(tabRef, 'change', () => {
setTabItemSelected(tabRef.current?.value || "Chat")
})
useAsyncEffect(async () => {
const re = await Client.invoke('Chat.getInfo', {
token: data.access_token,
target: target,
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "對話錯誤")
setChatInfo(re.data as Chat)
}, [target])
console.log(tabItemSelected)
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
}} {...props}>
<mdui-tabs ref={tabRef} value="Chat" style={{
position: 'sticky',
display: "flex",
flexDirection: "column",
height: "100%",
}}>
<mdui-tab value="Chat">{
chatInfo.title
}</mdui-tab>
<mdui-tab value="Settings"></mdui-tab>
<mdui-tab-panel slot="panel" value="Chat" style={{
display: tabItemSelected == "Chat" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
<div style={{
display: "flex",
justifyContent: "center",
marginTop: "15px"
}}>
<mdui-button variant="text"></mdui-button>
</div>
<MessageContainer>
</MessageContainer>
{
// 输入框
}
<div style={{
display: 'flex',
alignItems: 'center',
paddingBottom: '0.1rem',
paddingTop: '0.1rem',
height: '4rem',
position: 'sticky',
bottom: '2px',
marginLeft: '5px',
marginRight: '4px',
backgroundColor: 'rgb(var(--mdui-color-background))',
}}>
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize max-rows={1} style={{
marginRight: '10px',
}}></mdui-text-field>
<mdui-button-icon slot="end-icon" icon="more_vert" style={{
marginRight: '6px',
}}></mdui-button-icon>
<mdui-button-icon icon="send" style={{
marginRight: '7px',
}}></mdui-button-icon>
</div>
</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-tabs>
</div>
)
}

View File

@@ -1,83 +0,0 @@
import Avatar from "../Avatar.tsx"
/**
* 一条消息
* @param { Object } param
* @param { "left" | "right" } [param.direction="left"] 消息方向
* @param { String } [param.avatar] 头像链接
* @param { String } [param.nickName] 昵称
* @returns { React.JSX.Element }
*/
export default function Message({ direction = 'left', avatar, nickName, children, ...props } = {}) {
let isAtRight = direction == 'right'
return (
<div
slot="trigger"
style={{
width: "100%",
display: "flex",
justifyContent: isAtRight ? "flex-end" : "flex-start",
flexDirection: "column"
}}
{...props}>
<div
style={{
display: "flex",
justifyContent: isAtRight ? "flex-end" : "flex-start",
}}>
{
// 发送者昵称(左)
isAtRight && <span
style={{
alignSelf: "center",
fontSize: "90%"
}}>
{nickName}
</span>
}
{
// 发送者头像
}
<Avatar
src={avatar}
text={nickName}
style={{
width: "43px",
height: "43px",
margin: "11px"
}} />
{
// 发送者昵称(右)
!isAtRight && <span
style={{
alignSelf: "center",
fontSize: "90%"
}}>
{nickName}
</span>
}
</div>
<mdui-card
variant="elevated"
style={{
maxWidth: 'var(--whitesilk-widget-message-maxwidth)', // (window.matchMedia('(pointer: fine)') && "50%") || (window.matchMedia('(pointer: coarse)') && "77%"),
minWidth: "0%",
[isAtRight ? "marginRight" : "marginLeft"]: "55px",
marginTop: "-5px",
padding: "15px",
alignSelf: isAtRight ? "flex-end" : "flex-start",
}}>
<span
id="msg"
style={{
fontSize: "94%"
}}>
{
// 消息内容
children
}
</span>
</mdui-card>
</div>
)
}

View File

@@ -1,20 +0,0 @@
/**
* 消息容器
* @returns { React.JSX.Element }
*/
export default function MessageContainer({ children, style, ...props } = {}) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'center',
marginBottom: '20px',
height: "100%",
...style,
}}
{...props}>
{children}
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -1,55 +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"
interface Args extends React.HTMLAttributes<HTMLElement> {
chat: Chat
chatInfoDialogRef: React.MutableRefObject<Dialog>
}
export default function ChatInfoDialog({ chat, chatInfoDialogRef }: Args) {
const [isMySelf, setIsMySelf] = React.useState(false)
useAsyncEffect(async () => {
const re = await Client.invoke("Chat.getInfo", {
token: data.access_token,
target: chat.id,
})
})
return (
<mdui-dialog close-on-overlay-click close-on-esc ref={chatInfoDialogRef}>
<div style={{
display: 'flex',
alignItems: 'center',
}}>
<Avatar src={chat?.avatar} text={chat?.nickname} style={{
width: '50px',
height: '50px',
}} />
<span style={{
marginLeft: "15px",
fontSize: '16.5px',
}}>{user?.nickname}</span>
</div>
<mdui-divider style={{
marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider>
<mdui-list>
{!isMySelf && <mdui-list-item icon="edit" rounded></mdui-list-item>}
{
isMySelf && <>
<mdui-list-item icon="edit" rounded></mdui-list-item>
<mdui-list-item icon="settings" rounded></mdui-list-item>
<mdui-list-item icon="lock" rounded></mdui-list-item>
</>
}
</mdui-list>
</mdui-dialog>
)
}

View File

@@ -1,54 +0,0 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts";
interface Refs {
loginInputAccountRef: React.MutableRefObject<TextField | null>
loginInputPasswordRef: React.MutableRefObject<TextField | null>
loginDialogRef: React.MutableRefObject<Dialog | null>
registerDialogRef: React.MutableRefObject<Dialog | null>
}
export default function LoginDialog({
loginInputAccountRef,
loginInputPasswordRef,
loginDialogRef,
registerDialogRef
}: Refs) {
const loginButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
const registerButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
useEventListener(registerButtonRef, 'click', () => registerDialogRef.current!.open = true)
useEventListener(loginButtonRef, 'click', async () => {
const account = loginInputAccountRef.current!.value
const password = loginInputPasswordRef.current!.value
const re = await Client.invoke("User.login", {
account: account,
password: CryptoJS.SHA256(password).toString(CryptoJS.enc.Hex),
})
if (checkApiSuccessOrSncakbar(re, "登錄失敗")) return
data.access_token = re.data!.access_token as string
data.apply()
location.reload()
})
return (
<mdui-dialog headline="登錄" ref={loginDialogRef}>
<mdui-text-field label="用戶 ID / 用戶名" ref={loginInputAccountRef}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="密碼" type="password" toggle-password ref={loginInputPasswordRef}></mdui-text-field>
<mdui-button slot="action" variant="text" ref={registerButtonRef}></mdui-button>
<mdui-button slot="action" variant="text" ref={loginButtonRef}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -1,67 +0,0 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui";
import useEventListener from "../useEventListener.ts";
import Client from "../../api/Client.ts";
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts";
import * as CryptoJS from 'crypto-js'
interface Refs {
loginInputAccountRef: React.MutableRefObject<TextField | null>
loginInputPasswordRef: React.MutableRefObject<TextField | null>
registerInputUserNameRef: React.MutableRefObject<TextField | null>
registerInputNickNameRef: React.MutableRefObject<TextField | null>
registerInputPasswordRef: React.MutableRefObject<TextField | null>
registerDialogRef: React.MutableRefObject<Dialog | null>
}
export default function RegisterDialog({
loginInputAccountRef,
loginInputPasswordRef,
registerInputUserNameRef,
registerInputNickNameRef,
registerInputPasswordRef,
registerDialogRef
}: Refs) {
const registerBackButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
const doRegisterButtonRef: React.MutableRefObject<Button | null> = React.useRef(null)
useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false)
useEventListener(doRegisterButtonRef, 'click', async () => {
const username = registerInputUserNameRef.current!.value
const re = await Client.invoke("User.register", {
username: username,
nickname: registerInputNickNameRef.current!.value,
password: CryptoJS.SHA256(registerInputPasswordRef.current!.value).toString(CryptoJS.enc.Hex),
})
if (checkApiSuccessOrSncakbar(re, "注冊失敗")) return
loginInputAccountRef.current!.value = username == "" ? re.data!.userid as string : username
loginInputPasswordRef.current!.value = registerInputPasswordRef.current!.value
registerInputUserNameRef.current!.value = ""
registerInputNickNameRef.current!.value = ""
registerInputPasswordRef.current!.value = ""
registerDialogRef.current!.open = false
snackbar({
message: "注冊成功!",
placement: "top",
})
})
return (
<mdui-dialog headline="注冊" ref={registerDialogRef}>
<mdui-text-field label="用戶名 (可選)" ref={registerInputUserNameRef}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="昵稱" ref={registerInputNickNameRef}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef}></mdui-text-field>
<mdui-button slot="action" variant="text" ref={registerBackButtonRef}></mdui-button>
<mdui-button slot="action" variant="text" ref={doRegisterButtonRef}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -1,81 +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 Avatar from "../Avatar.tsx"
import User from "../../api/client_data/User.ts"
interface Refs {
userProfileDialogRef: React.MutableRefObject<Dialog>
user: User
}
export default function UserProfileDialog({
userProfileDialogRef,
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",
})
})
return (
<mdui-dialog close-on-overlay-click close-on-esc ref={userProfileDialogRef}>
<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',
}} />
<span style={{
marginLeft: "15px",
fontSize: '16.5px',
}}>{user?.nickname}</span>
</div>
<mdui-divider style={{
marginTop: "10px",
marginBottom: "10px",
}}></mdui-divider>
<mdui-list>
{!isMySelf && <mdui-list-item icon="edit" rounded></mdui-list-item>}
{
isMySelf && <>
<mdui-list-item icon="edit" rounded></mdui-list-item>
<mdui-list-item icon="settings" rounded></mdui-list-item>
<mdui-list-item icon="lock" rounded></mdui-list-item>
</>
}
</mdui-list>
</mdui-dialog>
)
}

View 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>
)
}

View 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>
}

View File

@@ -0,0 +1,29 @@
import { $ } from "mdui/jq"
import Avatar from "../Avatar.tsx"
import React from 'react'
import { Chat } from "lingchair-client-protocol"
import getClient from "../../getClient.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
chat: Chat
active?: boolean
}
export default function AllChatsListItem({ chat, active, ...prop }: Args) {
const title = chat.getTitle()
const ref = React.useRef<HTMLElement>(null)
return (
<mdui-list-item active={active} ref={ref} rounded style={{
marginTop: '3px',
marginBottom: '3px',
width: '100%',
}} {...prop as any}>
<span style={{
width: "100%",
}}>{title}</span>
<Avatar src={getClient().getUrlForFileByHash(chat.getAvatarFileHash() as string)} text={title} slot="icon" />
</mdui-list-item>
)
}

View 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>
}

View 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>
)
}

View 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>
)
}

View 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>
}

View File

@@ -1,21 +1,28 @@
import RecentChat from "../../api/client_data/RecentChat.ts"
import { $ } from "mdui/jq"
import Avatar from "../Avatar.tsx"
import React from 'react'
import getClient from "../../getClient.ts"
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
recentChat: RecentChat
openChatFragment: (id: string) => void
active?: boolean
}
export default function RecentsListItem({ recentChat, openChatFragment, active }: Args) {
const { id, title, avatar, content } = recentChat
export default function RecentsListItem({ recentChat, active, ...props }: Args) {
const { id, title, avatar_file_hash, content } = recentChat.bean
const itemRef = React.useRef<HTMLElement>(null)
React.useEffect(() => {
$(itemRef.current!.shadowRoot).find('.headline').css('margin-top', '3px')
})
return (
<mdui-list-item rounded style={{
marginTop: '3px',
marginBottom: '3px',
}} onClick={() => openChatFragment(id)} active={active}>
}} active={active} ref={itemRef} {...props}>
{title}
<Avatar src={avatar} text={title} slot="icon" />
<Avatar src={getClient().getUrlForFileByHash(avatar_file_hash!)} text={title} slot="icon" />
<span slot="description"
style={{
width: "100%",

View 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>
)
}

View File

@@ -1,86 +0,0 @@
import React from "react"
import User from "../../api/client_data/User.ts"
import ContactsListItem from "./ContactsListItem.tsx"
import useEventListener from "../useEventListener.ts"
import { ListItem, TextField } from "mdui"
import useAsyncEffect from "../useAsyncEffect.ts"
import Client from "../../api/Client.ts"
import data from "../../Data.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
display: boolean
openChatFragment: (id: string) => void
}
export default function ContactsList({
display,
openChatFragment,
...props
}: Args) {
const searchRef = React.useRef<HTMLElement>(null)
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
const [searchText, setSearchText] = React.useState('')
const [contactsList, setContactsList] = React.useState([
{
id: '1',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "麻油衣酱",
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "Maya Fey",
},
] as User[])
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
})
useAsyncEffect(async () => {
})
return <mdui-list style={{
overflowY: 'auto',
paddingLeft: '10px',
paddingRight: '10px',
display: display ? undefined : 'none',
height: '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',
marginBottom: '15px',
}} icon="person_add"></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((user) =>
searchText == '' ||
user.nickname.includes(searchText) ||
user.id.includes(searchText) ||
user.username?.includes(searchText)
).map((v) =>
<ContactsListItem
/* active={!isMultiSelecting && false}
onClick={(e) => {
const self = (e.target as ListItem)
if (isMultiSelecting)
self.active = !self.active
else
void(0)
}} */
key={v.id}
contact={v} />
)
}
</mdui-list>
}

View File

@@ -1,27 +0,0 @@
import { ListItem } from "mdui";
import User from "../../api/client_data/User.ts"
import Avatar from "../Avatar.tsx"
import React from 'react'
interface Args extends React.HTMLAttributes<HTMLElement> {
contact: User
active?: boolean
}
export default function ContactsListItem({ contact, ...prop }: Args) {
const { id, nickname, avatar } = contact
const ref = React.useRef<HTMLElement>(null)
return (
<mdui-list-item ref={ref} rounded style={{
marginTop: '3px',
marginBottom: '3px',
width: '100%',
}} {...prop as any}>
<span style={{
width: "100%",
}}>{nickname}</span>
<Avatar src={avatar} text="title" slot="icon" />
</mdui-list-item>
)
}

View File

@@ -1,56 +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',
display: display ? undefined : 'none',
height: '100%',
}} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
marginTop: '5px',
marginBottom: '13px',
paddingLeft: '10px',
}}></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>
}

View 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>
)
}

View 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
}

View 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)
}

View File

@@ -0,0 +1,17 @@
import { $ } from 'mdui/jq'
export default function openImageViewer(src: string) {
$('#image-viewer-dialog-inner').empty()
const e = new Image()
e.onload = () => ($('#image-viewer-dialog-inner').get(0) as any).scaleTo(0.1, {
// Transform origin. Can be a number, or string percent, eg "50%"
originX: '50%',
originY: '50%',
// Should the transform origin be relative to the container, or content?
relativeTo: 'container',
})
e.src = src
$('#image-viewer-dialog-inner').append(e)
$('#image-viewer-dialog').attr('open', 'true')
}

View File

@@ -0,0 +1,34 @@
// https://www.xiabingbao.com/post/crypto/js-crypto-randomuuid-qxcuqj.html
// 在此表示感謝
export default function randomUUID() {
// crypto - 只支持在安全的上下文使用
if (typeof crypto === 'object') {
if (typeof crypto.randomUUID === 'function') {
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
return crypto.randomUUID()
}
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
const callback = (c: string) => {
const num = Number(c)
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16)
};
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback)
}
}
// 隨機數 - fallback
let timestamp = new Date().getTime()
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
let random = Math.random() * 16
if (timestamp > 0) {
random = (timestamp + random) % 16 | 0
timestamp = Math.floor(timestamp / 16)
} else {
random = (perforNow + random) % 16 | 0
perforNow = Math.floor(perforNow / 16)
}
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16)
})
}

View File

@@ -1,5 +1,4 @@
import { snackbar as mduiSnackbar, Snackbar } from "mdui"
import ApiCallbackMessage from "../api/ApiCallbackMessage.ts"
interface Options {
/**
@@ -83,16 +82,8 @@ interface SnackbarOptions extends Options {
message: string
}
export function checkApiSuccessOrSncakbar(re: ApiCallbackMessage, msg_ahead: string, opinions_override: Options = {}): Snackbar | null {
return re.code != 200 ? snackbar(
Object.assign({
message: `${msg_ahead}: ${re.msg} [${re.code}]`,
placement: "top",
} as SnackbarOptions, opinions_override)
) : null
}
export function snackbar(opinions: SnackbarOptions) {
export default function showSnackbar(opinions: SnackbarOptions) {
opinions.autoCloseDelay == null && (opinions.autoCloseDelay = 3500)
opinions.placement == null && (opinions.placement = 'top')
return mduiSnackbar(opinions)
}

1
client/utils/sleep.ts Normal file
View File

@@ -0,0 +1 @@
export default (t: number) => new Promise((res) => setTimeout(res, t))

View File

@@ -1,8 +1,8 @@
import * as React from 'react'
export default function useEventListener<T extends HTMLElement | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
export default function useEventListener<T extends HTMLElement | undefined | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
React.useEffect(() => {
ref.current!.addEventListener(eventName, callback)
return () => ref.current!.removeEventListener(eventName, callback)
return () => ref.current?.removeEventListener(eventName, callback)
}, [ref, eventName, callback])
}

View File

@@ -1,12 +1,52 @@
import { defineConfig } from 'vite'
import deno from '@deno/vite-plugin'
import react from '@vitejs/plugin-react'
import config from '../server/config.ts'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
import { execSync } from 'child_process'
import fs from 'node:fs/promises'
const gitHash = execSync('git rev-parse --short HEAD')
.toString()
.trim()
const gitFullHash = execSync('git rev-parse HEAD')
.toString()
.trim()
const gitBranch = execSync('git rev-parse --abbrev-ref HEAD')
.toString()
.trim()
const versionEnv = {
define: {
__APP_VERSION__: JSON.stringify(JSON.parse(await fs.readFile('package.json', 'utf-8')).version),
__GIT_HASH__: JSON.stringify(gitHash),
__GIT_HASH_FULL__: JSON.stringify(gitFullHash),
__GIT_BRANCH__: JSON.stringify(gitBranch),
__BUILD_TIME__: JSON.stringify(new Date().toLocaleString('zh-CN')),
}
}
function gitHashPlugin() {
return {
name: 'git-hash-plugin',
config() {
return versionEnv
}
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [deno(), react()],
plugins: [
react(),
nodePolyfills({
include: ['crypto', 'stream', 'vm'],
globals: {
Buffer: true,
global: true,
process: true,
},
}),
gitHashPlugin(),
],
build: {
sourcemap: true,
outDir: "." + config.data_path + '/page_compiled',

View File

@@ -1,13 +0,0 @@
{
"tasks": {
"server": "deno task build && deno run --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
"debug": "deno task build && deno run --watch --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
"build": "cd ./client && deno task build"
},
"imports": {
"chalk": "npm:chalk@5.4.1",
"file-type": "npm:file-type@21.0.0",
"express": "npm:express@5.1.0",
"socket.io": "npm:socket.io@4.8.1"
}
}

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
version: '3'
services:
lingchair:
container_name: lingchair
build:
context: .
dockerfile: Dockerfile
environment:
TZ: Asia/Shanghai
ports:
- "3601:3601"
restart: always
volumes:
- ./thewhitesilk_config.json:/app/thewhitesilk_config.json
- ./thewhitesilk_data:/app/thewhitesilk_data
network_mode: bridge

8
docker-update.sh Normal file
View File

@@ -0,0 +1,8 @@
docker stop lingchair
docker rm lingchair
docker rmi lingchair
echo "remove success"
git pull
echo "pull success"
docker compose up -d
echo "update success"

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

View File

@@ -8,8 +8,9 @@ type ApiCallbackMessage = {
* 404: Not Found
* 500: 伺服器端錯誤
* 501: 伺服器端不支持請求的功能
* -1: 请求错误
*/
code: 200 | 400 | 401 | 403 | 404 | 500 | 501,
code: 200 | 400 | 401 | 403 | 404 | 500 | 501 | -1,
data?: { [key: string]: unknown },
}
export default ApiCallbackMessage

View File

@@ -0,0 +1,66 @@
export type CallMethod =
// 用户验证
"User.auth" |
"User.register" |
"User.login" |
"User.refreshAccessToken" |
// 账号
"User.setAvatar" |
"User.updateProfile" |
"User.getMyInfo" |
"User.resetPassword" |
// 用户资料
"User.getInfo" |
// 收藏对话列表
"User.getMyContacts" |
"User.addContacts" |
"User.removeContacts" |
// 最近对话列表
"User.getMyRecentChats" |
// 全部对话列表
"User.getMyAllChats" |
// 对话信息
"Chat.getInfo" |
"Chat.getAnotherUserIdFromPrivate" |
"Chat.getMembers" |
// 对话设置
"Chat.updateSettings" |
"Chat.setAvatar" |
// 对话创建
"Chat.createGroup" |
"Chat.getIdForPrivate" |
// 入群请求
"Chat.processJoinRequest" |
"Chat.sendJoinRequest" |
"Chat.getJoinRequests" |
// 对话成员
"Chat.removeMembers" |
"Chat.quit" |
// 对话消息
"Chat.sendMessage" |
"Chat.getMessageHistory"
// (废弃) 文件上传
// "Chat.uploadFile"
export type ClientEvent =
// 对话收消息
"Client.onMessage"
export const CallableMethodBeforeAuth = [
"User.auth",
"User.register",
"User.login",
"User.refreshAccessToken",
]

7
internal-shared/main.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './ApiDeclare.ts'
import ApiCallbackMessage from './ApiCallbackMessage.ts'
export type { ApiCallbackMessage }
import randomUUID from './randomUUID.ts'
export { randomUUID }

Some files were not shown because too many files have changed in this diff Show More