Compare commits

..

33 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
55 changed files with 1429 additions and 425 deletions

7
.gitignore vendored
View File

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

8
.vscode/launch.json vendored
View File

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

View File

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

View File

@@ -11,10 +11,12 @@ WORKDIR /app
COPY --exclude=.git --exclude=.gitignore --exclude=Dockerfile --exclude=readme.md --exclude=thewhitesilk_config.json --exclude=thewhitesilk_data . .
# 缓存依赖并构建项目
RUN deno task build
RUN npm run install-dependencies
RUN npm run build-client
# 暴露应用端口(根据你的应用调整端口号)
EXPOSE 3601
# 启动服务
CMD ["deno", "task", "build-and-run-server"]
CMD ["npm", "run", "server"]

View File

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

View File

@@ -19,6 +19,11 @@ export default class Chat extends BaseClientObject {
* 实例化方法
* ================================================
*/
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)

View File

@@ -3,34 +3,11 @@ 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 } from "lingchair-internal-shared"
import { CallableMethodBeforeAuth, randomUUID } from "lingchair-internal-shared"
import CallbackError from "./CallbackError.ts"
import Message from "./Message.ts"
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"
export {
Chat,
User,
UserMySelf,
UserBean,
ChatBean,
MessageBean,
RecentChatBean,
JoinRequestBean,
}
export type { GroupSettingsBean }
export default class LingChairClient {
declare client: Socket
declare access_token: string
@@ -59,7 +36,7 @@ export default class LingChairClient {
auth: {
...args.io?.auth,
device_id: this.device_id,
session_id: crypto.randomUUID(),
session_id: randomUUID(),
},
})
this.client.on("The_White_Silk", (name: ClientEvent, data: any, _callback: (ret: unknown) => void) => {
@@ -154,33 +131,37 @@ export default class LingChairClient {
}
}
/**
* 客户端上线
* 进行身份验证以接受客户端事件
*
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
*
* 不会逐一尝试
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
*
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
*
* 多个验证方式不会逐一尝试
*/
async authOrThrow(args: {
refresh_token?: string,
access_token?: string,
account?: string,
password?: string,
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))
throw new Error('Access/Refresh token or account & password required')
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
this.refresh_token = args.refresh_token
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)
if (re.code == 200) {
access_token = re.data!.access_token as string | undefined
else
this.refresh_token = args.refresh_token
} else
throw new CallbackError(re)
}
@@ -189,9 +170,10 @@ export default class LingChairClient {
account: args.account,
password: crypto.createHash('sha256').update(args.password).digest('hex'),
})
if (re.code == 200)
if (re.code == 200) {
access_token = re.data!.access_token as string | undefined
else
this.refresh_token = re.data!.refresh_token as string
} else
throw new CallbackError(re)
}
@@ -203,18 +185,6 @@ export default class LingChairClient {
else
throw new CallbackError(re)
}
async register(args: {
nickname: string,
username?: string,
password: string,
}) {
try {
await this.registerOrThrow(args)
return true
} catch (_) {
return false
}
}
getBaseHttpUrl() {
const url = new URL(this.server_url)
return (({
@@ -227,6 +197,17 @@ export default class LingChairClient {
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,
@@ -239,10 +220,11 @@ export default class LingChairClient {
const re = await this.invoke('User.register', {
nickname,
username,
password,
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,

View File

@@ -14,10 +14,15 @@ export default class User extends BaseClientObject {
* 实例化方法
* ================================================
*/
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(_) {
} catch (_) {
return null
}
}

View File

@@ -156,7 +156,7 @@ export default class UserMySelf extends User {
token: this.client.access_token
})
if (re.code == 200)
return re.data!.recent_chats as ChatBean[]
return (re.data!.favourite_chats || re.data!.contacts_list) as ChatBean[]
throw new CallbackError(re)
}
async getMyFavouriteChats() {

View File

@@ -1,7 +0,0 @@
{
"imports": {
"socket.io-client": "npm:socket.io-client@4.8.1",
"lingchair-internal-shared": "../internal-shared/mod.ts",
"marked": "npm:marked@16.3.0"
}
}

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

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,51 +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: {
refresh_token?: string
split_sizes: number[]
apply(): void
access_token?: string
device_id: string
}
}
}
// @ts-ignore: 忽略...
// 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,36 +0,0 @@
import process from 'node:process'
import child_process from 'node:child_process'
import fs from 'node:fs/promises'
function spawn(exec: string, args: string[]) {
child_process.spawnSync(exec, args, {
stdio: [process.stdin, process.stdout, process.stderr]
})
}
function runBuild() {
const args = [
"run",
"-A",
"--node-modules-dir",
]
let i = 0
for (const arg of process.argv) {
if (i > 1)
args.push(arg)
i++
}
spawn('deno', args)
}
if (process.platform == 'android') {
try {
await fs.stat('./node_modules/.deno/rollup@4.50.1/node_modules/rollup/')
} catch (e) {
spawn('deno', ['install', '--node-modules-dir=auto'])
}
spawn('sh', ["fix-build-on-android.sh"])
}
runBuild()

71
client/data.ts Normal file
View File

@@ -0,0 +1,71 @@
import crypto from 'node:crypto'
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
class Aes {
static randomIv() {
return crypto.randomBytes(12)
}
static normalizeKey(key: string, keyLength = 32) {
const hash = crypto.createHash('sha256')
hash.update(key)
const keyBuffer = hash.digest()
return keyLength ? keyBuffer.subarray(0, keyLength) : keyBuffer
}
static encrypt(data: string, key: string) {
const iv = this.randomIv()
return Buffer.concat([iv, crypto.createCipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(data)]).toString('hex')
}
static decrypt(data: string, key: string) {
const buffer = Buffer.from(data, 'hex')
const iv = buffer.subarray(0, 12)
return crypto.createDecipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(buffer.subarray(12)).toString()
}
}
// 尽可能防止被窃取, 虽然理论上还是会被窃取
const key = crypto.createHash('sha256').update(location.host + '_TWS_姐姐_' + navigator.userAgent).digest().toString('base64')
if (dataIsEmpty) localStorage.tws_data = Aes.encrypt('{}', key)
let _data_cached
try {
_data_cached = JSON.parse(Aes.decrypt(localStorage.tws_data, key))
} catch (e) {
console.warn("数据解密失败, 使用空数据...", e)
_data_cached = {}
}
type IData = {
refresh_token?: string
split_sizes: number[]
apply(): void
access_token?: string
device_id: string
}
declare global {
interface Window {
data?: IData
}
}
const data = new Proxy({} as IData, {
get(_obj, k) {
if (k == '_cached') return _data_cached
if (k == 'apply') return () => localStorage.tws_data = Aes.encrypt(JSON.stringify(_data_cached), key)
return _data_cached[k]
},
set(_obj, k, v) {
if (k == '_cached') return false
_data_cached[k] = v
return true
}
})
if (new URL(location.href).searchParams.get('export_data') == 'true') {
window.data = data
console.warn("警告: 将 data 暴露到 window 有可能会导致令牌泄露!")
}
export default data

View File

@@ -1,43 +0,0 @@
{
"tasks": {
"build": "deno run --allow-run --allow-env --allow-read checkIsAndroidAndBuild.ts npm:vite build",
"build-watch": "deno run --allow-run --allow-env --allow-read checkIsAndroidAndBuild.ts npm:vite --watch build"
},
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"jsxImportSourceTypes": "@types/react"
},
"nodeModulesDir": "auto",
"links": [
"../mdui_patched"
],
"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",
"lightningcss": "npm:lightningcss@^1.30.2",
"react": "npm:react@18.3.1",
"react-dom": "npm:react-dom@18.3.1",
"terser": "npm:terser@^5.44.1",
"vite": "npm:vite@7.0.6",
"rollup": "npm:@rollup/wasm-node@4.48.0",
"chalk": "npm:chalk@5.4.1",
"mdui": "npm:mdui@2.1.4",
"split.js": "npm:split.js@1.3.2",
"crypto-js": "npm:crypto-js@4.2.0",
"socket.io-client": "npm:socket.io-client@4.8.1",
"marked": "npm:marked@16.3.0",
"dompurify": "npm:dompurify@3.2.7",
"pinch-zoom-element": "npm:pinch-zoom-element@1.1.1",
"ua-parser-js": "npm:ua-parser-js@2.0.6",
"lingchair-internal-shared": "../internal-shared/mod.ts",
"lingchair-client-protocol": "../mod.ts"
}
}

8
client/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="mdui/jsx.zh-cn.d.ts" />
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
declare const __GIT_HASH__: string
declare const __GIT_HASH_FULL__: string
declare const __GIT_BRANCH__: string
declare const __BUILD_TIME__: string

View File

@@ -1,3 +0,0 @@
rm -r ./node_modules/.deno/rollup@4.50.1/node_modules/rollup/
cp -r ./node_modules/.deno/@rollup+wasm-node@4.48.0/node_modules/@rollup/wasm-node/ node_modules/.deno/rollup@4.50.1/node_modules/rollup/
echo Replaced rollup with @rollup/wasm-node successfully

View File

@@ -1,7 +1,8 @@
import { LingChairClient } from 'lingchair-client-protocol'
import data from "./Data.ts"
import data from "./data.ts"
import { UAParser } from 'ua-parser-js'
import randomUUID from "./utils/randomUUID.ts"
import { randomUUID } from 'lingchair-internal-shared'
import performAuth from './performAuth.ts'
if (!data.device_id) {
const ua = new UAParser(navigator.userAgent)

BIN
client/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -2,6 +2,8 @@ import 'mdui/mdui.css'
import 'mdui'
import { breakpoint } from "mdui"
import './env.d.ts'
import * as React from 'react'
import ReactDOM from 'react-dom/client'
@@ -14,6 +16,14 @@ import './ui/chat-elements/chat-text-container.ts'
import './ui/chat-elements/chat-quote.ts'
import Main from "./ui/Main.tsx"
import performAuth from './performAuth.ts'
try {
await performAuth({})
} catch (e) {
console.log("验证失败", e)
}
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(Main))
const onResize = () => {

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

View File

@@ -1,12 +1,16 @@
import data from "./Data.ts";
import data from "./data.ts"
import getClient from "./getClient.ts"
/**
* 客户端上线
* 进行身份验证以接受客户端事件
*
* 优先级: 账号密码 > 提供刷新令牌 > 储存的刷新令牌
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
*
* 不会逐一尝试
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
*
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
*
* 多个验证方式不会逐一尝试
*/
export default async function performAuth(args: {
refresh_token?: string
@@ -19,8 +23,9 @@ export default async function performAuth(args: {
password: args.password,
})
else {
await getClient().authOrThrow({ refresh_token: args.refresh_token ? args.refresh_token : data.refresh_token })
await getClient().authOrThrow({ refresh_token: args.refresh_token ? args.refresh_token : data.refresh_token, ignore_all_empty: true })
}
data.refresh_token = getClient().getCachedRefreshToken()
data.access_token = getClient().getCachedAccessToken()
data.apply()
}

View File

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

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

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

View File

@@ -2,6 +2,8 @@ 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>
@@ -20,6 +22,7 @@ export default function AvatarMySelf({
})
useAsyncEffect(async () => {
await sleep(200)
const mySelf = await UserMySelf.getMySelfOrThrow(getClient())
setArgs({
text: mySelf.getNickName(),
@@ -27,5 +30,5 @@ export default function AvatarMySelf({
})
})
return <Avatar avatarRef={avatarRef} {...props} {...args}></Avatar>
}
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>
}

View File

@@ -1,103 +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 = {
openChatFragment: React.useRef()
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={{}}>
<div style={{
display: "flex",
position: 'relative',
width: 'calc(var(--whitesilk-window-width) - 80px)',
height: 'var(--whitesilk-window-height)',
}}>
{
/**
* Default: 侧边列表提供列表切换
*/
!isMobileUI() ?
<mdui-navigation-rail contained value="Recents">
<mdui-button-icon slot="top">
<AvatarMySelf />
</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="Contacts"></mdui-navigation-rail-item>
<mdui-navigation-rail-item icon="chat--outlined" active-icon="chat--filled" value="AllChats"></mdui-navigation-rail-item>
<mdui-dropdown trigger="hover" slot="bottom">
<mdui-button-icon icon="add" slot="trigger"></mdui-button-icon>
<mdui-menu>
<mdui-menu-item icon="person_add"></mdui-menu-item>
<mdui-menu-item icon="group_add"></mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
</mdui-navigation-rail>
/**
* Mobile: 底部导航栏提供列表切换
*/
: <mdui-top-app-bar style={{
position: 'sticky',
marginTop: '3px',
marginRight: '6px',
marginLeft: '15px',
top: '0px',
<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)',
}}>
<mdui-top-app-bar-title>{
({
Recents: "最近对话",
Contacts: "收藏对话",
AllChats: "所有对话",
})['Recents']
}</mdui-top-app-bar-title>
<div style={{
flexGrow: 1,
}}></div>
<mdui-dropdown trigger="hover">
<mdui-button-icon icon="add" slot="trigger"></mdui-button-icon>
<mdui-menu>
<mdui-menu-item icon="person_add"></mdui-menu-item>
<mdui-menu-item icon="group_add"></mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
<mdui-button-icon icon="settings"></mdui-button-icon>
<mdui-button-icon>
<AvatarMySelf />
</mdui-button-icon>
</mdui-top-app-bar>
}
{
/**
* Mobile: 指定高度的容器
* Default: 侧边列表
*/
<div style={isMobileUI() ? {
display: 'flex',
height: 'calc(100% - 80px - 67px)',
width: '100%',
} : {}} id="SideBar">
{
// 将子路由渲染到此处
<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>
</div>
}
{
/**
* Mobile: 底部导航栏提供列表切换
* Default: 侧边列表提供列表切换
*/
isMobileUI() && <mdui-navigation-bar 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="Contacts"></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>
<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

@@ -1,5 +1,23 @@
import { createContext } from 'react'
import { Chat, UserMySelf } from "lingchair-client-protocol"
import { createContext } from "use-context-selector"
const MainSharedContext = createContext({})
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,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

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

@@ -1,39 +1,4 @@
import { $ } from 'mdui/jq'
import 'pinch-zoom-element'
document.body.appendChild(new DOMParser().parseFromString(`
<mdui-dialog id="image-viewer-dialog" fullscreen="fullscreen">
<style>
#image-viewer-dialog::part(panel) {
background: rgba(0, 0, 0, 0) !important;
padding: 0 !important;
}
#image-viewer-dialog>mdui-button-icon[icon=close] {
z-index: 114514;
position: fixed;
top: 15px;
right: 15px;
color: #ffffff
}
#image-viewer-dialog>mdui-button-icon[icon=open_in_new] {
z-index: 114514;
position: fixed;
top: 15px;
right: 65px;
color: #ffffff
}
</style>
<mdui-button-icon icon="open_in_new"
onclick="window.open(document.querySelector('#image-viewer-dialog-inner > *').src, '_blank')">
</mdui-button-icon>
<mdui-button-icon icon="close" onclick="this.parentNode.open = false">
</mdui-button-icon>
<pinch-zoom id="image-viewer-dialog-inner" style="width: var(--whitesilk-window-width); height: var(--whitesilk-window-height);">
</pinch-zoom>
</mdui-dialog>
`, 'text/html').body.firstChild as Node)
export default function openImageViewer(src: string) {
$('#image-viewer-dialog-inner').empty()

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

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

View File

@@ -1,6 +1,6 @@
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)

View File

@@ -1,40 +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: [react(), deno()],
plugins: [
react(),
nodePolyfills({
include: ['crypto', 'stream', 'vm'],
globals: {
Buffer: true,
global: true,
process: true,
},
}),
gitHashPlugin(),
],
build: {
sourcemap: true,
outDir: "." + config.data_path + '/page_compiled',
minify: 'terser',
cssMinify: 'lightningcss',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('mdui')) {
return 'mdui'
}
if (id.includes('crypto-js')) {
return 'cryptojs'
}
if (id.includes('split.js')) {
return 'splitjs'
}
if (id.includes('marked')) {
return 'marked'
}
return 'vendor'
}
return 'main'
},
}
}
},
optimizeDeps: {
include: ['socket.io-client']
}
})

View File

@@ -1,18 +0,0 @@
{
"tasks": {
"server": "deno run --allow-read --allow-write --allow-env --allow-net --allow-sys --allow-run=deno ./server/main.ts",
"build-and-run-server": "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",
"cookie-parser": "npm:cookie-parser@1.4.7",
"express-fileupload": "npm:express-fileupload@1.5.2",
"lingchair-internal-shared": "./internal-shared/mod.ts",
"socket.io-client": "npm:socket.io-client@4.8.1"
}
}

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 383 KiB

View File

@@ -2,3 +2,6 @@ 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)
})
}

6
mod.ts
View File

@@ -1,6 +0,0 @@
import LingChairClient from './client-protocol/LingChairClient.ts'
export * from './client-protocol/LingChairClient.ts'
export {
LingChairClient
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "lingchair",
"type": "module",
"workspaces": [
"./mdui_patched",
"./client-protocol",
"./internal-shared",
"./client"
],
"scripts": {
"install-dependencies": "npm install -d --workspaces",
"debug": "npm run build-client && npx tsx --watch ./server/main.ts",
"server": "npx tsx ./server/main.ts",
"build-client": "cd client && npm run build"
},
"dependencies": {
"chalk": "5.4.1",
"cookie-parser": "1.4.7",
"express": "5.1.0",
"express-fileupload": "1.5.2",
"file-type": "21.0.0",
"lingchair-internal-shared": "*",
"socket.io": "4.8.1"
}
}

View File

@@ -1,7 +1,4 @@
import readline from 'node:readline'
import process from "node:process"
import chalk from "chalk"
import child_process from "node:child_process"
import createLingChairServer from "./server.ts"
import config from "./config.ts"
@@ -9,21 +6,3 @@ const { httpServer } = await createLingChairServer()
httpServer.listen(config.server.listen)
console.log(chalk.green(`API & Web 服务已启动, 端口为 ${config.server.listen.port}`))
function help() {
console.log(chalk.yellow("输入 b 或者执行 deno task build 以编译前端"))
}
help()
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
rl.on('line', (text) => {
if (text == 'b') {
console.log(chalk.green("重新编译..."))
child_process.spawnSync("deno", ["task", "build"], {
stdio: [process.stdin, process.stdout, process.stderr]
})
help()
}
})

View File

@@ -33,6 +33,17 @@ export default async function createLingChairServer() {
res.sendFile(path.resolve(file!.getFilePath()))
file.updateLastUsedTime()
})
// For client-side router
app.get(/.*/, (req, res, next) => {
if (
req.path.startsWith('/uploaded_files/')
|| req.path === '/config.json'
) {
return next()
}
res.sendFile(path.resolve(config.data_path + '/page_compiled/index.html'))
})
await fs.mkdir(config.data_path + '/upload_cache', { recursive: true })
try {