Compare commits

...

460 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
CrescentLeaf
eaf0f98058 update 2025-09-20 20:32:26 +08:00
CrescentLeaf
1acc73c7b4 chore: make lint happy 2025-09-20 20:14:47 +08:00
CrescentLeaf
23df74ddac ui: 微調 資料卡 昵稱字體 2025-09-20 20:13:20 +08:00
CrescentLeaf
70478584b7 chore: 精簡類型注解 2025-09-20 20:12:57 +08:00
CrescentLeaf
90295f0d38 fix: useAsyncEffect loops 2025-09-20 19:52:04 +08:00
CrescentLeaf
5ff726d834 fix(ui): 右側的面板沒有吃滿寬度 2025-09-20 19:51:41 +08:00
CrescentLeaf
ab1bc844ab fix: WTF Where is my React 2025-09-20 18:41:46 +08:00
CrescentLeaf
167b157134 refactor: 封裝 useAsyncEffect 2025-09-20 18:26:08 +08:00
CrescentLeaf
3b98fc4de3 feat(wip): 多選聯絡人 2025-09-20 18:14:52 +08:00
CrescentLeaf
4a32fd216b feat: search for recentschat 2025-09-20 18:00:12 +08:00
CrescentLeaf
af9b0d7cf2 fix: 由於未知原因導致的 輸入框 逃竄到 Tab 的 change 事件, 造成 Tab Panel 顯示異常 2025-09-20 17:35:12 +08:00
CrescentLeaf
c82d718fa7 feat: search contact by nickname/id/username 2025-09-20 17:29:12 +08:00
CrescentLeaf
fc3df592bc chore: make code looks happy 2025-09-20 17:01:05 +08:00
CrescentLeaf
5ce42bf651 updated
浪費了半天時間, 索性移除了聯絡人分組的支援
2025-09-20 16:57:17 +08:00
CrescentLeaf
6a8acd4717 ui: remember split sizes state 2025-09-20 08:18:28 +08:00
CrescentLeaf
03f6f2743f chore: add "not impl" for not exists func 2025-09-20 00:33:22 +08:00
CrescentLeaf
13c42ddf38 chore: add UserBean for client 2025-09-20 00:32:55 +08:00
CrescentLeaf
c13913f08a feat(wip): 聯絡人 2025-09-20 00:32:37 +08:00
CrescentLeaf
b7ce12ff5e ui: 添加打開對話提示 2025-09-20 00:32:21 +08:00
CrescentLeaf
dd7c578534 fix: auth not check user is exists 2025-09-20 00:32:00 +08:00
CrescentLeaf
d473ff81bd feat(wip): 對話 2025-09-20 00:31:36 +08:00
CrescentLeaf
c6bfca0482 fix(typo): p->a<-ivate 2025-09-19 22:50:55 +08:00
CrescentLeaf
b1e7f3e485 fix(ui): 侧边联络人列表显示溢出 2025-09-19 20:04:56 +08:00
CrescentLeaf
a85ea56bb7 feat(wip): MessagesManager 2025-09-14 14:33:16 +08:00
CrescentLeaf
ee670f86b6 refactor: 解耦側邊列表 2025-09-14 14:33:04 +08:00
CrescentLeaf
85b48475de chore: remove useless code 2025-09-14 14:32:37 +08:00
CrescentLeaf
0af3e7a449 feat(wip): 實現 ChatPrivate 2025-09-14 14:32:24 +08:00
CrescentLeaf
2b54a7a13a chore: 統一 可選成員 寫法 2025-09-14 14:31:53 +08:00
CrescentLeaf
4cc4866db1 CHORE: FIX DENO LANGUAGE SERVER OUT OF MEMORY CAUSED BY COMPILED FRONTEND 2025-09-14 13:55:25 +08:00
CrescentLeaf
a3d5e93240 feat(wip): Chat impl 2025-09-14 00:37:03 +08:00
CrescentLeaf
ed494413fd feat(wip): Chat.getInfo 2025-09-14 00:36:51 +08:00
CrescentLeaf
557234841d ui: ChatFragment 使用分面板的樣式 2025-09-14 00:18:56 +08:00
CrescentLeaf
ea17ab2ddd chore: rename ChatFragment. js -> ts 2025-09-14 00:12:50 +08:00
CrescentLeaf
20ef8a8514 chore: make lint happy 2025-09-14 00:11:13 +08:00
CrescentLeaf
124879f11f ui: AppMobile 界面長寬修正 2025-09-13 23:50:38 +08:00
CrescentLeaf
125938b8be feat(ui): (wip)移動端界面! 2025-09-13 22:14:35 +08:00
CrescentLeaf
2208a2d292 ui: 調整修改頭像 snackbar 位置 2025-09-13 13:02:50 +08:00
CrescentLeaf
1deec533ad fix: Android 上強制使用 @rollup/wasm-node 2025-09-13 12:28:02 +08:00
CrescentLeaf
633cfed87b feat: setNickName setUserName getMyInfo 2025-09-13 00:40:32 +08:00
CrescentLeaf
c51a6508e4 feat: access myUserProfile through Client 2025-09-13 00:40:09 +08:00
CrescentLeaf
12c2e13505 feat(wip): user profile dialog 2025-09-13 00:39:58 +08:00
CrescentLeaf
372e71bc1c chore: make User.ts declare better 2025-09-13 00:39:25 +08:00
CrescentLeaf
5fee5dd363 chore: useless change 2025-09-13 00:39:03 +08:00
CrescentLeaf
2ee73416e0 chore: change vite config: sourcemap: inline -> true 2025-09-13 00:38:51 +08:00
CrescentLeaf
73a1536df7 chore: add new Api declaretion 2025-09-13 00:38:17 +08:00
CrescentLeaf
8ebad65140 chore: import Avatar.jsx -> .tsx 2025-09-13 00:37:56 +08:00
CrescentLeaf
6896a1f8af refactor: Avatar.jsx -> .tsx 2025-09-13 00:37:25 +08:00
CrescentLeaf
b30035d5a8 feat: access uploaded files through http 2025-09-13 00:37:08 +08:00
CrescentLeaf
6b0e781fdf fix: file upload failed by folder not created 2025-09-13 00:36:48 +08:00
CrescentLeaf
fd6ceb82df chore: remove useless & add getAvatarFileHash 2025-09-13 00:36:12 +08:00
609 changed files with 99717 additions and 1056 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"
}
]
}

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

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

28
client-protocol/main.ts Normal file
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,47 +0,0 @@
// @ts-types="npm:@types/crypto-js"
import * as CryptoJS from 'crypto-js'
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
const aes = {
enc: (data: string, key: string) => CryptoJS.AES.encrypt(data, key).toString(),
dec: (data: string, key: string) => CryptoJS.AES.decrypt(data, key).toString(CryptoJS.enc.Utf8),
}
const key = location.host + '_TWS_姐姐'
if (dataIsEmpty) localStorage.tws_data = aes.enc('{}', key)
let _dec = aes.dec(localStorage.tws_data, key)
if (_dec == '') _dec = '{}'
const _data_cached = JSON.parse(_dec)
// 類型定義
declare global {
interface Window {
data: {
apply(): void
access_token?: string
}
}
}
// deno-lint-ignore no-window
(window.data == null) && (window.data = new Proxy({
apply() {}
}, {
get(_obj, k) {
if (k == '_cached') return _data_cached
if (k == 'apply') return () => localStorage.tws_data = aes.enc(JSON.stringify(_data_cached), key)
return _data_cached[k]
},
set(_obj, k, v) {
if (k == '_cached') return false
_data_cached[k] = v
return true
}
}))
// deno-lint-ignore no-window
export default window.data

View File

@@ -1,7 +0,0 @@
export type CallMethod =
"User.auth" |
"User.register" |
"User.login"
export type ClientEvent =
"Client.onMessage"

View File

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

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 | null
declare content: string
}

View File

@@ -1,7 +0,0 @@
export default class User {
declare id: string
declare count: number
declare username: string | null
declare nickname: string
declare avatar: string | null
}

71
client/data.ts Normal file
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,28 +0,0 @@
{
"tasks": {
"build": "deno run -A --node-modules-dir npm:vite build",
"build-watch": "deno run -A --node-modules-dir npm:vite --watch build"
},
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"jsxImportSourceTypes": "@types/react"
},
"imports": {
"@deno/vite-plugin": "npm:@deno/vite-plugin@1.0.5",
"@types/react": "npm:@types/react@18.3.1",
"@types/react-dom": "npm:@types/react-dom@18.3.1",
"@vitejs/plugin-react": "npm:@vitejs/plugin-react@4.7.0",
"react": "npm:react@18.3.1",
"react-dom": "npm:react-dom@18.3.1",
"vite": "npm:vite@7.0.6",
"chalk": "npm:chalk@5.4.1",
"mdui": "npm:mdui@2.1.4",
"split.js": "npm:split.js@1.3.2",
"crypto-js": "npm:crypto-js@4.2.0",
"socket.io-client": "npm:socket.io-client@4.8.1"
}
}

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

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,23 +1,30 @@
import 'mdui/mdui.css'
import 'mdui'
import { $ } from "mdui/jq"
import { breakpoint, Dialog } from "mdui"
import { breakpoint } from "mdui"
import './env.d.ts'
import * as React from 'react'
import ReactDOM from 'react-dom/client'
// deno-lint-ignore no-window no-window-prefix
new URL(location.href).searchParams.get('debug') == 'true' && window.addEventListener('error', ({ message, filename, lineno, colno, error }) => {
const m = $("#ErrorDialog_Message")
const d = $("#ErrorDialog").get(0) as Dialog
const s = d.open
d.open = true
m.html((s ? `${m.html()}<br/><br/>` : '') + `${message} (${filename || 'unknown'}:${lineno}:${colno})`)
})
import './ui/chat-elements/chat-image.ts'
import './ui/chat-elements/chat-video.ts'
import './ui/chat-elements/chat-file.ts'
import './ui/chat-elements/chat-text.ts'
import './ui/chat-elements/chat-mention.ts'
import './ui/chat-elements/chat-text-container.ts'
import './ui/chat-elements/chat-quote.ts'
import Main from "./ui/Main.tsx"
import App from './ui/App.tsx'
import performAuth from './performAuth.ts'
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(App, null))
try {
await performAuth({})
} catch (e) {
console.log("验证失败", e)
}
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(Main))
const onResize = () => {
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
@@ -29,3 +36,6 @@ const onResize = () => {
// deno-lint-ignore no-window no-window-prefix
window.addEventListener('resize', onResize)
onResize()
const config = await fetch('config.json').then((re) => re.json())
config.title && (document.title = config.title)

32
client/package.json Normal file
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

@@ -51,10 +51,10 @@ html {
/* 防止小尺寸图片模糊*/
* {
image-rendering: crisp-edges;
image-rendering: -moz-crisp-edges;
image-rendering: -o-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}
@@ -67,3 +67,7 @@ html {
.gutter.gutter-horizontal {
cursor: col-resize;
}
a {
color: rgb(var(--mdui-color-primary));
}

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,176 +0,0 @@
import Client from "../api/Client.ts"
import data from "../Data.ts"
import ChatFragment from "./chat/ChatFragment.jsx"
import LoginDialog from "./dialog/LoginDialog.tsx"
import ContactsListItem from "./main/ContactsListItem.jsx"
import RecentsListItem from "./main/RecentsListItem.jsx"
import useEventListener from './useEventListener.ts'
import User from "../api/client_data/User.ts"
import RecentChat from "../api/client_data/RecentChat.ts"
import * as React from 'react'
import { Button, Dialog, NavigationRail, TextField } from "mdui"
import Split from 'split.js'
import 'mdui/jsx.zh-cn.d.ts'
import { checkApiSuccessOrSncakbar } from "./snackbar.ts";
import RegisterDialog from "./dialog/RegisterDialog.tsx";
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
}
}
}
}
export default function App() {
const [recentsList, setRecentsList] = React.useState([
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "麻油衣酱",
content: "成步堂君, 我又坐牢了("
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
title: "Maya Fey",
content: "我是绫里真宵, 是一名灵媒师~"
},
] as RecentChat[])
const [contactsMap, setContactsMap] = React.useState({
: [
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "麻油衣酱",
},
{
id: '0',
avatar: "https://www.court-records.net/mugshot/aa6-004-maya.png",
nickname: "Maya Fey",
},
],
} as unknown as { [key: string]: User[] })
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationRailRef: React.MutableRefObject<NavigationRail | null> = React.useRef(null)
useEventListener(navigationRailRef as React.MutableRefObject<NavigationRail>, 'change', (event) => {
setNavigationItemSelected((event.target as HTMLElement as NavigationRail).value as string)
})
const loginDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
const loginInputAccountRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const loginInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerDialogRef: React.MutableRefObject<Dialog | null> = React.useRef(null)
const registerInputUserNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerInputNickNameRef: React.MutableRefObject<TextField | null> = React.useRef(null)
const registerInputPasswordRef: React.MutableRefObject<TextField | null> = React.useRef(null)
React.useEffect(() => {
; (async () => {
Split(['#SideBar', '#ChatFragment'], {
sizes: [25, 75],
minSize: [200, 400],
gutterSize: 2,
})
Client.connect()
const re = await Client.invoke("User.auth", {
access_token: data.access_token || '',
})
if (re.code == 401)
loginDialogRef.current!.open = true
else if (re.code != 200)
if (checkApiSuccessOrSncakbar(re, "驗證失敗")) return
})()
}, [])
return (
<div style={{
display: "flex",
position: 'relative',
width: 'calc(var(--whitesilk-window-width) - 80px)',
height: 'var(--whitesilk-window-height)',
}}>
<LoginDialog
loginDialogRef={loginDialogRef}
loginInputAccountRef={loginInputAccountRef}
loginInputPasswordRef={loginInputPasswordRef}
registerDialogRef={registerDialogRef} />
<RegisterDialog
registerDialogRef={registerDialogRef}
registerInputUserNameRef={registerInputUserNameRef}
registerInputNickNameRef={registerInputNickNameRef}
registerInputPasswordRef={registerInputPasswordRef}
loginInputAccountRef={loginInputAccountRef}
loginInputPasswordRef={loginInputPasswordRef} />
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon icon="menu" slot="top"></mdui-button-icon>
<mdui-navigation-rail-item icon="watch_later--outlined" value="Recents"></mdui-navigation-rail-item>
<mdui-navigation-rail-item icon="contacts--outlined" value="Contacts"></mdui-navigation-rail-item>
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
</mdui-navigation-rail>
{
// 侧边列表
}
<div id="SideBar">
{
// 最近聊天
<mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
display: navigationItemSelected == "Recents" ? undefined : 'none'
}}>
{
recentsList.map((v) =>
<RecentsListItem
key={v.id}
nickName={v.title}
avatar={v.avatar}
content={v.content} />
)
}
</mdui-list>
}
{
// 联系人列表
<mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
display: navigationItemSelected == "Contacts" ? undefined : 'none'
}}>
<mdui-collapse accordion value={Object.keys(contactsMap)[0]}>
{
Object.keys(contactsMap).map((v) =>
<mdui-collapse-item key={v} value={v}>
<mdui-list-subheader slot="header">{v}</mdui-list-subheader>
{
contactsMap[v].map((v2) =>
<ContactsListItem
key={v2.id}
nickName={v2.nickname}
avatar={v2.avatar} />
)
}
</mdui-collapse-item>
)
}
</mdui-collapse>
</mdui-list>
}
</div>
{
// 聊天页面
}
<ChatFragment id="ChatFragment" />
</div>
)
}

View File

@@ -1,15 +0,0 @@
export default function Avatar({ src, text, icon = 'person', ...props } = {}) {
return (
src ? <mdui-avatar {...props}>
<img src={src} alt={'(头像)' + text || ''} />
</mdui-avatar>
: (
text ? <mdui-avatar {...props}>
{
text.substring(0, 0)
}
</mdui-avatar>
: <mdui-avatar icon={icon} {...props} />
)
)
}

25
client/ui/Avatar.tsx Normal file
View File

@@ -0,0 +1,25 @@
interface Args extends React.HTMLAttributes<HTMLElement> {
src?: string
text?: string
icon?: string
avatarRef?: React.LegacyRef<HTMLElement>
}
export default function Avatar({
src,
text,
icon = 'person',
avatarRef,
...props
}: Args) {
if (src != null && src != '')
return <mdui-avatar ref={avatarRef} {...props} src={src} />
else if (text != null && text != '')
return <mdui-avatar ref={avatarRef} {...props}>
{
text.substring(0, 1)
}
</mdui-avatar>
else
return <mdui-avatar icon={icon} ref={avatarRef} {...props} />
}

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,65 +0,0 @@
import Message from "./Message.jsx"
import MessageContainer from "./MessageContainer.jsx"
import * as React from 'react'
export default function ChatFragment({ ...props } = {}) {
const messageList = React.useState([])
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
}} {...props}>
<mdui-top-app-bar style={{
position: 'sticky',
}}>
<mdui-button-icon icon="menu"></mdui-button-icon>
<mdui-top-app-bar-title>Title</mdui-top-app-bar-title>
<mdui-button-icon icon="more_vert"></mdui-button-icon>
</mdui-top-app-bar>
<div style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}>
<div style={{
display: "flex",
justifyContent: "center",
}}>
<mdui-button variant="text">加載更多</mdui-button>
</div>
<MessageContainer>
</MessageContainer>
{
// 输入框
}
<div style={{
display: 'flex',
alignItems: 'center',
paddingBottom: '0.1rem',
paddingTop: '0.1rem',
height: '4rem',
position: 'sticky',
bottom: '2px',
marginLeft: '5px',
marginRight: '4px',
backgroundColor: 'rgb(var(--mdui-color-background))',
}}>
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize max-rows="1" style={{
marginRight: '10px',
}}></mdui-text-field>
<mdui-button-icon slot="end-icon" icon="more_vert" style={{
marginRight: '6px',
}}></mdui-button-icon>
<mdui-button-icon icon="send" style={{
marginRight: '7px',
}}></mdui-button-icon>
</div>
</div>
</div >
)
}

View File

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

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,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

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

@@ -0,0 +1,36 @@
import { $ } from "mdui/jq"
import Avatar from "../Avatar.tsx"
import React from 'react'
import getClient from "../../getClient.ts"
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
recentChat: RecentChat
active?: boolean
}
export default function RecentsListItem({ recentChat, active, ...props }: Args) {
const { id, title, avatar_file_hash, content } = recentChat.bean
const itemRef = React.useRef<HTMLElement>(null)
React.useEffect(() => {
$(itemRef.current!.shadowRoot).find('.headline').css('margin-top', '3px')
})
return (
<mdui-list-item rounded style={{
marginTop: '3px',
marginBottom: '3px',
}} active={active} ref={itemRef} {...props}>
{title}
<Avatar src={getClient().getUrlForFileByHash(avatar_file_hash!)} text={title} slot="icon" />
<span slot="description"
style={{
width: "100%",
display: "inline-block",
whiteSpace: "nowrap", /* 禁止换行 */
overflow: "hidden", /* 隐藏溢出内容 */
textOverflow: "ellipsis", /* 显示省略号 */
}}>{content}</span>
</mdui-list-item>
)
}

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,16 +0,0 @@
import Avatar from "../Avatar.jsx"
export default function ContactsListItem({ nickName, avatar }) {
return (
<mdui-list-item rounded style={{
marginTop: '3px',
marginBottom: '3px',
width: '100%',
}}>
<span style={{
width: "100%",
}}>{nickName}</span>
<Avatar src={avatar} text="title" slot="icon" />
</mdui-list-item>
)
}

View File

@@ -1,21 +0,0 @@
import Avatar from "../Avatar.jsx"
export default function RecentsListItem({ nickName, avatar, content }) {
return (
<mdui-list-item rounded style={{
marginTop: '3px',
marginBottom: '3px',
}}>
{nickName}
<Avatar src={avatar} text={nickName} slot="icon" />
<span slot="description"
style={{
width: "100%",
display: "inline-block",
whiteSpace: "nowrap", /* 禁止换行 */
overflow: "hidden", /* 隐藏溢出内容 */
textOverflow: "ellipsis", /* 显示省略号 */
}}>{content}</span>
</mdui-list-item>
)
}

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) {
opinions.autoCloseDelay == null && (opinions.autoCloseDelay = 2500)
export default function showSnackbar(opinions: SnackbarOptions) {
opinions.autoCloseDelay == null && (opinions.autoCloseDelay = 3500)
opinions.placement == null && (opinions.placement = 'top')
return mduiSnackbar(opinions)
}

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

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

View File

@@ -0,0 +1,8 @@
import React from "react"
export default function useAsyncEffect(func: Function, deps?: React.DependencyList) {
React.useEffect(() => {
func()
// 警告: 不添加 deps 有可能導致無限執行
}, deps || [])
}

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

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 }

View File

@@ -0,0 +1,5 @@
{
"name": "lingchair-internal-shared",
"type": "module",
"main": "./main.ts"
}

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') {
// crypto-browserify 没有这个方法
if (typeof crypto.randomUUID === 'function') {
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
return crypto.randomUUID()
}
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
const callback = (c: string) => {
const num = Number(c)
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16)
};
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback)
}
}
// 随机数
let timestamp = new Date().getTime()
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
let random = Math.random() * 16
if (timestamp > 0) {
random = (timestamp + random) % 16 | 0
timestamp = Math.floor(timestamp / 16)
} else {
random = (perforNow + random) % 16 | 0
perforNow = Math.floor(perforNow / 16)
}
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16)
})
}

7
license Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 月有陰晴圓缺(CrescentLeaf/MoonLeeeaf)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

21
mdui_patched/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016-present zdhxiong@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

49
mdui_patched/README.md Normal file
View File

@@ -0,0 +1,49 @@
# mdui
[![GitHub version](https://badge.fury.io/gh/zdhxiong%2Fmdui.svg)](https://badge.fury.io/gh/zdhxiong%2Fmdui)
[![npm version](https://img.shields.io/npm/v/mdui.svg)](https://www.npmjs.com/package/mdui)
[![CDNJS](https://img.shields.io/cdnjs/v/mdui.svg)](https://cdnjs.com/libraries/mdui)
[官网](https://www.mdui.org) | [文档](https://www.mdui.org/docs/2/)
使用 Web Components 实现,遵循 Material You 设计规范的 Web 前端组件库。
* **Web Components**mdui 组件全部使用 Web Components 开发,使用组件就像使用 `<div>` 标签一样简单。
* **Material You**:遵循最新的 Material Design 3Material You设计规范使你的产品美观、易用。
* **动态配色**支持根据给定颜色值或给定一张图片mdui 能自动计算出颜色值,生成整套配色方案,并在所有 mdui 组件中生效。
* **暗色模式**:所有组件都支持暗色模式、及支持根据操作系统设置自动切换亮色模式和暗色模式。
* **轻量级**gzip 后的 CSS + JavaScript 仅 85KB使用按需导入可进一步减小体积使加载更迅速。
* **IDE 支持**:在 VSCode 和 WebStorm 中能获得完美的代码提示。且提供了 VSCode 扩展和 WebStorm 插件,使开发更便捷。
* **兼容所有框架**mdui 能兼容 Vue、React、Angular 等框架,只要在浏览器上运行的应用,都能使用 mdui。
* **TypeScript 支持**mdui 完全使用 TypeScript 开发,拥有完美的类型提示。
* **无依赖**:不需要依赖任何第三方库,节约网络流量,使加载更迅速。
* **组件丰富**mdui 包含 30 多个组件,及十余个工具函数,常用组件都有。
* **Material Icons 图标库**:提供了超过 1 万个图标组件,可按需导入所需图标。
* **低学习成本**:只需懂一点 HTML、CSS、JavaScript 的基础知识,就能使用 mdui。
## 安装
```bash
npm install mdui --save
```
### 导入 CSS 及 JS 文件
```js
import 'mdui/mdui.css';
import 'mdui';
```
### 使用组件
```html
<mdui-button>Button</mdui-button>
```
## 赞助
赞助以帮助 mdui 持续更新
![通过支付宝赞助](https://ww1.sinaimg.cn/large/63f511e3gy1ffhw0jj5n4j206o089dge.jpg)
![通过微信赞助](https://ww1.sinaimg.cn/large/63f511e3gy1ffhw0vkaeaj206o0890ta.jpg)
[![通过 Paypal 赞助](https://ww1.sinaimg.cn/large/63f511e3gy1fff6937xzbj203w00y3yc.jpg)](https://www.paypal.me/zdhxiong/5)

1
mdui_patched/components/avatar.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export * from './avatar/index.js';

View File

@@ -0,0 +1 @@
export * from './avatar/index.js';

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