Compare commits

...

45 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
123 changed files with 1586 additions and 3675 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,18 +3,10 @@ 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 User from "./User.ts"
import UserMySelf from "./UserMySelf.ts"
import { CallableMethodBeforeAuth, randomUUID } from "lingchair-internal-shared"
import CallbackError from "./CallbackError.ts"
import Chat from "./Chat.ts"
import { CallableMethodBeforeAuth } from "lingchair-internal-shared"
import Message from "./Message.ts"
export {
User,
Chat,
UserMySelf,
}
import Message from "./Message.ts"
export default class LingChairClient {
declare client: Socket
@@ -44,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) => {
@@ -106,6 +98,25 @@ export default class LingChairClient {
})
})
}
/**
* 建议在 auth 返回 true 时调用
*/
getCachedAccessToken() {
return this.access_token
}
/**
* 建议在 auth 返回 true 时调用
*/
getCachedRefreshToken() {
return this.refresh_token
}
/**
* 客户端上线
*
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
*
* 不会逐一尝试
*/
async auth(args: {
refresh_token?: string,
access_token?: string,
@@ -119,27 +130,38 @@ export default class LingChairClient {
return false
}
}
/**
* 进行身份验证以接受客户端事件
*
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
*
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
*
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
*
* 多个验证方式不会逐一尝试
*/
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)
}
@@ -148,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)
}
@@ -162,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 (({
@@ -186,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,
@@ -198,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

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

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

@@ -1,5 +1,7 @@
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"
@@ -154,9 +156,19 @@ 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() {
try {
return await this.getMyFavouriteChatsOrThrow()
} catch (_) {
return []
}
}
async getMyFavouriteChatsOrThrow() {
return (await this.getMyFavouriteChatBeansOrThrow()).map((bean) => new Chat(this.client, bean))
}
/*
* ================================================
* 最近对话
@@ -177,6 +189,16 @@ export default class UserMySelf extends User {
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))
}
/*
* ================================================
* 所有对话
@@ -197,4 +219,14 @@ export default class UserMySelf extends User {
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

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

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,12 +0,0 @@
export default class EventBus {
static events: { [key: string]: () => void } = {}
static on(eventName: string, func: () => void) {
this.events[eventName] = func
}
static off(eventName: string) {
delete this.events[eventName]
}
static emit(eventName: string) {
this.events[eventName]()
}
}

View File

@@ -1,20 +0,0 @@
// https://stackoverflow.com/questions/29085197/how-do-you-json-stringify-an-es6-map
export default class MapJson {
static replacer(key: unknown, value: unknown) {
if (value instanceof Map) {
return {
dataType: 'Map',
value: Array.from(value.entries()), // or with spread: value: [...value]
}
} else {
return value
}
}
static reviver(key: unknown, value: any) {
if (value?.dataType === 'Map') {
return new Map(value.value)
}
return value
}
}

View File

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

View File

@@ -1 +0,0 @@
export * from 'lingchair-internal-shared'

View File

@@ -1,205 +0,0 @@
import { io, Socket } from 'socket.io-client'
import { CallMethod, ClientEvent, CallableMethodBeforeAuth } from './ApiDeclare.ts'
import ApiCallbackMessage from './ApiCallbackMessage.ts'
import User from "./client_data/User.ts"
import data from "../Data.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../ui/snackbar.ts"
import randomUUID from "../randomUUID.ts"
class Client {
static sessionId = randomUUID()
static myUserProfile?: User
static socket?: Socket
static events: { [key: string]: ((data: unknown) => void)[] } = {}
static connected = false
static connect() {
if (data.device_id == null)
data.device_id = randomUUID()
this.socket?.disconnect()
this.socket && delete this.socket
this.socket = io({
transports: ['websocket', 'polling', 'webtransport'],
auth: {
device_id: data.device_id,
session_id: this.sessionId,
},
})
this.socket!.on("connect", () => {
const auth = async () => {
this.connected = true
const s = snackbar({
message: '重新验证中...',
placement: 'top',
autoCloseDelay: 0,
})
let i = 1
const id = setInterval(() => {
s.textContent = `重新验证中... (${i}s)`
i++
}, 1000)
const re = await this.auth(data.access_token as string, 6000)
if (re.code != 200) {
if (re.code == -1) {
auth()
} else if (re.code != 401 && re.code != 400) {
const s2 = checkApiSuccessOrSncakbar(re, "重新验证失败")
s2!.autoCloseDelay = 0
s2!.action = "重试"
s2!.addEventListener('action-click', () => {
auth()
})
this.socket!.once("disconnect", () => {
s2!.open = false
})
}
}
clearTimeout(id)
s.open = false
}
auth()
})
this.socket!.on("disconnect", () => {
this.connected = false
const s = snackbar({
message: '重新连接服务器中...',
placement: 'top',
autoCloseDelay: 0,
})
let i = 1
const id = setInterval(() => {
s.textContent = `重新连接服务器中... (${i}s)`
i++
this.socket!.connect()
}, 1000)
this.socket!.once('connect', () => {
s.open = false
clearTimeout(id)
})
})
this.socket!.on("The_White_Silk", (name: string, data: unknown, callback: (ret: unknown) => void) => {
try {
if (name == null || data == null) return
this.events[name]?.forEach((v) => v(data))
} catch (e) {
console.error(e)
}
})
}
static invoke(method: CallMethod, args: object = {}, timeout: number = 10000, refreshAndRetryLimit: number = 3, forceRefreshAndRetry: boolean = false): Promise<ApiCallbackMessage> {
// 在 未初始化 / 未建立连接且调用非可调用接口 的时候进行延迟
if (this.socket == null || (!this.connected && !CallableMethodBeforeAuth.includes(method))) {
return new Promise((reslove) => {
setTimeout(async () => reslove(await this.invoke(method, args, timeout)), 500)
})
}
// 反之, 返回 Promise
return new Promise((resolve) => {
this.socket!.timeout(timeout).emit("The_White_Silk", method, args, async (err: Error, res: ApiCallbackMessage) => {
// 错误处理
if (err) return resolve({
code: -1,
msg: err.message.indexOf("timed out") != -1 ? "请求超时" : err.message,
})
// 在特殊的方法之中, 不予进行: 令牌刷新并重试
// 附带 retry 次数限制
if (
(
forceRefreshAndRetry ||
(
!CallableMethodBeforeAuth.includes(method)
&& res.code == 401
)
) && refreshAndRetryLimit > 0
) {
const token = await this.refreshAccessToken()
if (token) {
data.access_token = token
data.apply()
resolve(await this.invoke(method, {
...args,
[method == "User.auth" ? "access_token" : "token"]: token,
}, timeout, refreshAndRetryLimit - 1))
} else
resolve(res)
} else
resolve(res)
})
})
}
static async refreshAccessToken() {
const re = await this.invoke("User.refreshAccessToken", {
refresh_token: data.refresh_token
})
return re.data?.access_token as string
}
static async auth(token: string, timeout?: number) {
const re = await this.invoke("User.auth", {
access_token: token
}, timeout, 1, true)
if (re.code == 200) {
// 灵车: 你应该先 connected = true 再调用
await this.updateCachedProfile()
document.cookie = 'token=' + token
document.cookie = 'device_id=' + data.device_id
}
return re
}
static async uploadFileLikeApi(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('./upload_file', {
method: 'POST',
headers: {
"Token": data.access_token,
"Device-Id": data.device_id,
} as HeadersInit,
body: form,
credentials: 'omit',
})
const text = await (await re.blob()).text()
let json
try {
json = JSON.parse(text)
} catch(_) {}
return {
...(json == null ? {
msg: text
} : json),
code: re.status,
} as ApiCallbackMessage
}
static async uploadFile(fileName: string, fileData: ArrayBuffer | Blob | Response, chatId?: string) {
const re = await this.uploadFileLikeApi(fileName, fileData, chatId)
if (re.code != 200) throw new Error(re.msg)
return re.data!.hash as string
}
static async updateCachedProfile() {
this.myUserProfile = (await Client.invoke("User.getMyInfo", {
token: data.access_token
})).data as unknown as User
}
static on(eventName: ClientEvent, func: (data: unknown) => void) {
if (this.events[eventName] == null)
this.events[eventName] = []
if (this.events[eventName].indexOf(func) == -1)
this.events[eventName].push(func)
}
static off(eventName: ClientEvent, func: (data: unknown) => void) {
if (this.events[eventName] == null)
this.events[eventName] = []
const index = this.events[eventName].indexOf(func)
if (index != -1)
this.events[eventName].splice(index, 1)
}
}
export default Client

View File

@@ -1,36 +0,0 @@
import data from "../Data.ts"
import Client from "./Client.ts"
import Chat from "./client_data/Chat.ts"
import User from "./client_data/User.ts"
export default class DataCaches {
static userProfiles: { [key: string]: User} = {}
static async getUserProfile(userId: string): Promise<User> {
if (this.userProfiles[userId]) return this.userProfiles[userId]
const re = await Client.invoke("User.getInfo", {
token: data.access_token,
target: userId
})
if (re.code != 200) return {
id: '',
nickname: "",
}
return this.userProfiles[userId] = (re.data as unknown as User)
}
static chatInfos: { [key: string]: Chat} = {}
static async getChatInfo(chatId: string): Promise<Chat> {
if (this.chatInfos[chatId]) return this.chatInfos[chatId]
const re = await Client.invoke('Chat.getInfo', {
token: data.access_token,
target: chatId,
})
if (re.code != 200) return {
id: '',
title: '',
type: '' as any,
is_admin: false,
is_member: false,
}
return this.chatInfos[chatId] = (re.data as unknown as Chat)
}
}

View File

@@ -1,14 +0,0 @@
import ChatType from "./ChatType.ts"
export default class Chat {
declare type: ChatType
declare id: string
declare title: string
declare avatar_file_hash?: string
declare settings?: { [key: string]: unknown }
declare is_member: boolean
declare is_admin: boolean
[key: string]: unknown
}

View File

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

View File

@@ -1,14 +0,0 @@
interface GroupSettings {
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
[key: string]: unknown
}
export default GroupSettings

View File

@@ -1,8 +0,0 @@
export default class JoinRequest {
declare user_id: string
declare title: string
declare avatar?: string
declare reason?: string
[key: string]: unknown
}

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
import process from 'node:process'
import child_process from 'node:child_process'
import fs from 'node:fs/promises'
function spawn(exec: string, args: string[]) {
child_process.spawnSync(exec, args, {
stdio: [process.stdin, process.stdout, process.stderr]
})
}
function runBuild() {
const args = [
"run",
"-A",
"--node-modules-dir",
]
let i = 0
for (const arg of process.argv) {
if (i > 1)
args.push(arg)
i++
}
spawn('deno', args)
}
if (process.platform == 'android') {
try {
await fs.stat('./node_modules/.deno/rollup@4.50.1/node_modules/rollup/')
} catch (e) {
spawn('deno', ['install', '--node-modules-dir=auto'])
}
spawn('sh', ["fix-build-on-android.sh"])
}
runBuild()

71
client/data.ts Normal file
View File

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

View File

@@ -1,41 +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",
"lingchair-internal-shared": "../internal-shared/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,5 +0,0 @@
export default function escapeHTML(str: string) {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}

View File

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

19
client/getClient.ts Normal file
View File

@@ -0,0 +1,19 @@
import { LingChairClient } from 'lingchair-client-protocol'
import data from "./data.ts"
import { UAParser } from 'ua-parser-js'
import { randomUUID } from 'lingchair-internal-shared'
import performAuth from './performAuth.ts'
if (!data.device_id) {
const ua = new UAParser(navigator.userAgent)
data.device_id = `LingChair_Web_${ua.getOS() || 'unknown-os'}-${ua.getDevice().type || 'unknown_device'}-${randomUUID()}`
}
const client = new LingChairClient({
server_url: '',
device_id: data.device_id,
auto_fresh_token: true,
})
export default function getClient() {
return client
}

View File

@@ -1,3 +0,0 @@
export default function getUrlForFileByHash(file_hash?: string, defaultUrl?: string) {
return file_hash ? "uploaded_files/" + file_hash: defaultUrl
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -18,10 +18,7 @@
<body>
<div id="app"></div>
<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,53 +0,0 @@
import 'mdui/mdui.css'
import 'mdui'
import { breakpoint } from "mdui"
import * as React from 'react'
import ReactDOM from 'react-dom/client'
import './ui/custom-elements/chat-image.ts'
import './ui/custom-elements/chat-video.ts'
import './ui/custom-elements/chat-file.ts'
import './ui/custom-elements/chat-text.ts'
import './ui/custom-elements/chat-mention.ts'
import './ui/custom-elements/chat-text-container.ts'
import App from './ui/App.tsx'
import AppMobile from './ui/AppMobile.tsx'
import isMobileUI from "./ui/isMobileUI.ts"
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(isMobileUI() ? AppMobile : App, null))
import User from "./api/client_data/User.ts"
import Chat from "./api/client_data/Chat.ts"
// TODO: 无奈之举 以后会找更好的办法
declare global {
interface Window {
openUserInfoDialog: (user: User | string) => Promise<void>
openChatInfoDialog: (chat: Chat) => void
}
}
const onResize = () => {
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
// deno-lint-ignore no-window
document.body.style.setProperty('--whitesilk-window-width', window.innerWidth + 'px')
// deno-lint-ignore no-window
document.body.style.setProperty('--whitesilk-window-height', window.innerHeight + 'px')
}
// deno-lint-ignore no-window no-window-prefix
window.addEventListener('resize', onResize)
onResize()
// @ts-ignore 工作正常, 这里是获取为 URL 以便于构建
import sw from './sw.ts?worker&url'
if ("serviceWorker" in navigator)
try {
navigator.serviceWorker
.register(sw as URL)
} catch (e) {
console.error(e)
}
const config = await fetch('config.json').then((re) => re.json())
config.title && (document.title = config.title)

41
client/init.ts Normal file
View File

@@ -0,0 +1,41 @@
import 'mdui/mdui.css'
import 'mdui'
import { breakpoint } from "mdui"
import './env.d.ts'
import * as React from 'react'
import ReactDOM from 'react-dom/client'
import './ui/chat-elements/chat-image.ts'
import './ui/chat-elements/chat-video.ts'
import './ui/chat-elements/chat-file.ts'
import './ui/chat-elements/chat-text.ts'
import './ui/chat-elements/chat-mention.ts'
import './ui/chat-elements/chat-text-container.ts'
import './ui/chat-elements/chat-quote.ts'
import Main from "./ui/Main.tsx"
import performAuth from './performAuth.ts'
try {
await performAuth({})
} catch (e) {
console.log("验证失败", e)
}
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(Main))
const onResize = () => {
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
// deno-lint-ignore no-window
document.body.style.setProperty('--whitesilk-window-width', window.innerWidth + 'px')
// deno-lint-ignore no-window
document.body.style.setProperty('--whitesilk-window-height', window.innerHeight + 'px')
}
// deno-lint-ignore no-window no-window-prefix
window.addEventListener('resize', onResize)
onResize()
const config = await fetch('config.json').then((re) => re.json())
config.title && (document.title = config.title)

32
client/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "lingchair-client",
"type": "module",
"version": "0.1.0-alpha",
"scripts": {
"build": "npx vite build",
"build-watch": "npx vite --watch build"
},
"dependencies": {
"dompurify": "3.2.7",
"lingchair-internal-shared": "*",
"marked": "16.3.0",
"mdui": "2.1.4",
"pinch-zoom-element": "1.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "7.10.1",
"socket.io-client": "4.8.1",
"split.js": "1.3.2",
"ua-parser-js": "2.0.6",
"use-context-selector": "2.0.0"
},
"devDependencies": {
"@rollup/wasm-node": "4.48.0",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.7.0",
"chalk": "5.4.1",
"vite": "7.2.6",
"vite-plugin-node-polyfills": "^0.24.0"
}
}

31
client/performAuth.ts Normal file
View File

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

View File

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

View File

@@ -1,30 +0,0 @@
interface FetchEvent extends Event {
waitUntil: (p: Promise<unknown | void>) => void
request: Request
respondWith: (r: Response | Promise<Response>) => void
}
// 上传文件的代理与缓存
self.addEventListener("fetch", (e) => {
const event = e as FetchEvent
if (event.request.method != "GET" || event.request.url.indexOf("/uploaded_files/") == -1) return
event.respondWith(
(async () => {
const cache = await caches.open("LingChair-UploadedFile-Cache")
const cachedResponse = await cache.match(event.request)
if (cachedResponse) {
event.waitUntil(cache.add(event.request))
return cachedResponse
}
return fetch({
...event.request,
headers: {
...event.request.headers,
// 目前还不能获取 token
// localsotrage 在这里不可用
}
})
})()
)
})

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,281 +0,0 @@
import Client from "../api/Client.ts"
import data from "../Data.ts"
import ChatFragment from "./chat/ChatFragment.tsx"
import useEventListener from './useEventListener.ts'
import User from "../api/client_data/User.ts"
import Avatar from "./Avatar.tsx"
import * as React from 'react'
import { Dialog, NavigationRail, TextField } from "mdui"
import Split from 'split.js'
import 'mdui/jsx.zh-cn.d.ts'
import { checkApiSuccessOrSncakbar } from "./snackbar.ts"
import RegisterDialog from "./dialog/RegisterDialog.tsx"
import LoginDialog from "./dialog/LoginDialog.tsx"
import MyProfileDialog from "./dialog/MyProfileDialog.tsx"
import ContactsList from "./main/ContactsList.tsx"
import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts"
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
import Chat from "../api/client_data/Chat.ts"
import AddContactDialog from './dialog/AddContactDialog.tsx'
import CreateGroupDialog from './dialog/CreateGroupDialog.tsx'
import DataCaches from "../api/DataCaches.ts"
import getUrlForFileByHash from "../getUrlForFileByHash.ts"
import Message from "../api/client_data/Message.ts"
import EventBus from "../EventBus.ts"
import AllChatsList from "./main/AllChatsList.tsx";
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
slot?: string
}
}
}
}
export default function App() {
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationRailRef = React.useRef<NavigationRail>(null)
useEventListener(navigationRailRef, 'change', (event) => {
setNavigationItemSelected((event.target as HTMLElement as NavigationRail).value as string)
})
const loginDialogRef = React.useRef<Dialog>(null)
const loginInputAccountRef = React.useRef<TextField>(null)
const loginInputPasswordRef = React.useRef<TextField>(null)
const registerDialogRef = React.useRef<Dialog>(null)
const registerInputUserNameRef = React.useRef<TextField>(null)
const registerInputNickNameRef = React.useRef<TextField>(null)
const registerInputPasswordRef = React.useRef<TextField>(null)
const myProfileDialogRef = React.useRef<Dialog>(null)
const openMyProfileDialogButtonRef = React.useRef<HTMLElement>(null)
useEventListener(openMyProfileDialogButtonRef, 'click', (_event) => {
myProfileDialogRef.current!.open = true
})
const addContactDialogRef = React.useRef<Dialog>(null)
const createGroupDialogRef = React.useRef<Dialog>(null)
const chatInfoDialogRef = React.useRef<Dialog>(null)
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
const [myUserProfileCache, setMyUserProfileCache] = React.useState(null as unknown as User)
const [isShowChatFragment, setIsShowChatFragment] = React.useState(false)
const [currentChatId, setCurrentChatId] = React.useState('')
const [sharedFavouriteChats, setSharedFavouriteChats] = React.useState<Chat[]>([])
useAsyncEffect(async () => {
const split = Split(['#SideBar', '#ChatFragment'], {
sizes: data.split_sizes ? data.split_sizes : [25, 75],
minSize: [200, 400],
gutterSize: 2,
onDragEnd: function () {
data.split_sizes = split.getSizes()
data.apply()
}
})
Client.connect()
const re = await Client.auth(data.access_token || "")
if (re.code == 401)
loginDialogRef.current!.open = true
else if (re.code != 200) {
if (checkApiSuccessOrSncakbar(re, "验证失败")) return
} else if (re.code == 200) {
setMyUserProfileCache(Client.myUserProfile as User)
}
})
function openChatInfoDialog(chat: Chat) {
setChatInfo(chat)
chatInfoDialogRef.current!.open = true
}
function openChatFragment(chatId: string) {
setCurrentChatId(chatId)
setIsShowChatFragment(true)
}
async function openUserInfoDialog(user: User | string) {
const re = await Client.invoke("Chat.getIdForPrivate", {
token: data.access_token,
target: typeof user == 'object' ? user.id : user,
})
if (re.code != 200) {
checkApiSuccessOrSncakbar(re, '获取对话失败')
return
}
openChatInfoDialog(re.data as Chat)
/* if (typeof user == 'object') {
setUserInfo(user)
} else {
setUserInfo(await DataCaches.getUserProfile(user))
}
userProfileDialogRef.current!.open = true */
}
// deno-lint-ignore no-window
window.openUserInfoDialog = openUserInfoDialog
// deno-lint-ignore no-window
window.openChatInfoDialog = openChatInfoDialog
if ('Notification' in window) {
Notification.requestPermission()
React.useEffect(() => {
interface OnMessageData {
chat: string
msg: Message
}
async function onMessage(_event: unknown) {
EventBus.emit('RecentsList.updateRecents')
const event = _event as OnMessageData
if (currentChatId != event.chat) {
const chat = await DataCaches.getChatInfo(event.chat)
const user = await DataCaches.getUserProfile(event.msg.user_id)
const notification = new Notification(`${user.nickname} (对话: ${chat.title})`, {
icon: getUrlForFileByHash(chat.avatar_file_hash),
body: event.msg.text,
})
notification.addEventListener('click', () => {
setCurrentChatId(chat.id)
setIsShowChatFragment(true)
notification.close()
})
}
}
Client.on('Client.onMessage', onMessage)
return () => {
Client.off('Client.onMessage', onMessage)
}
}, [currentChatId])
}
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} />
<ChatInfoDialog
chatInfoDialogRef={chatInfoDialogRef as any}
openChatFragment={openChatFragment}
sharedFavouriteChats={sharedFavouriteChats}
chat={chatInfo} />
<MyProfileDialog
myProfileDialogRef={myProfileDialogRef as any}
user={myUserProfileCache} />
<AddContactDialog
addContactDialogRef={addContactDialogRef} />
<CreateGroupDialog
createGroupDialogRef={createGroupDialogRef} />
<mdui-navigation-rail contained value="Recents" ref={navigationRailRef}>
<mdui-button-icon slot="top">
<Avatar src={getUrlForFileByHash(myUserProfileCache?.avatar_file_hash)} text={myUserProfileCache?.nickname} avatarRef={openMyProfileDialogButtonRef} />
</mdui-button-icon>
<mdui-navigation-rail-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-rail-item>
<mdui-navigation-rail-item icon="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-button-icon icon="refresh" slot="bottom" onClick={() => {
EventBus.emit('RecentsList.updateRecents')
EventBus.emit('ContactsList.updateContacts')
EventBus.emit('AllChatsList.updateAllChats')
}}></mdui-button-icon>
<mdui-dropdown trigger="hover" slot="bottom">
<mdui-button-icon icon="add" slot="trigger"></mdui-button-icon>
<mdui-menu>
<mdui-menu-item icon="person_add" onClick={() => addContactDialogRef.current!.open = true}></mdui-menu-item>
<mdui-menu-item icon="group_add" onClick={() => createGroupDialogRef.current!.open = true}></mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
<mdui-button-icon icon="settings" slot="bottom"></mdui-button-icon>
</mdui-navigation-rail>
{
// 侧边列表
}
<div id="SideBar">
{
// 最近聊天
<RecentsList
openChatFragment={openChatFragment}
display={navigationItemSelected == "Recents"}
currentChatId={currentChatId} />
}
{
// 最近聊天
<AllChatsList
openChatInfoDialog={openChatInfoDialog}
display={navigationItemSelected == "AllChats"}
currentChatId={currentChatId} />
}
{
// 對話列表
<ContactsList
currentChatId={currentChatId}
openChatInfoDialog={openChatInfoDialog}
setSharedFavouriteChats={setSharedFavouriteChats}
addContactDialogRef={addContactDialogRef as any}
createGroupDialogRef={createGroupDialogRef as any}
display={navigationItemSelected == "Contacts"} />
}
</div>
{
// 聊天页面
}
<div id="ChatFragment" style={{
display: "flex",
width: '100%'
}}>
{
!isShowChatFragment && <div style={{
width: '100%',
textAlign: 'center',
alignSelf: 'center',
}}>
......
</div>
}
{
isShowChatFragment && <ChatFragment
target={currentChatId}
openUserInfoDialog={openUserInfoDialog}
openChatInfoDialog={openChatInfoDialog}
key={currentChatId} />
}
</div>
</div>
)
}

View File

@@ -1,275 +0,0 @@
import Client from "../api/Client.ts"
import data from "../Data.ts"
import ChatFragment from "./chat/ChatFragment.tsx"
import useEventListener from './useEventListener.ts'
import User from "../api/client_data/User.ts"
import Avatar from "./Avatar.tsx"
import * as React from 'react'
import { Dialog, NavigationBar, TextField } from "mdui"
import 'mdui/jsx.zh-cn.d.ts'
import { checkApiSuccessOrSncakbar } from "./snackbar.ts"
import RegisterDialog from "./dialog/RegisterDialog.tsx"
import LoginDialog from "./dialog/LoginDialog.tsx"
import MyProfileDialog from "./dialog/MyProfileDialog.tsx"
import ContactsList from "./main/ContactsList.tsx"
import RecentsList from "./main/RecentsList.tsx"
import useAsyncEffect from "./useAsyncEffect.ts"
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
import Chat from "../api/client_data/Chat.ts"
import AddContactDialog from './dialog/AddContactDialog.tsx'
import CreateGroupDialog from './dialog/CreateGroupDialog.tsx'
import getUrlForFileByHash from "../getUrlForFileByHash.ts"
import AllChatsList from "./main/AllChatsList.tsx";
import EventBus from "../EventBus.ts";
declare global {
namespace React {
namespace JSX {
interface IntrinsicAttributes {
id?: string
slot?: string
}
}
}
}
export default function AppMobile() {
const [navigationItemSelected, setNavigationItemSelected] = React.useState('Recents')
const navigationBarRef = React.useRef<NavigationBar>(null)
useEventListener(navigationBarRef, 'change', (event) => {
setNavigationItemSelected((event.target as HTMLElement as NavigationBar).value as string)
})
const loginDialogRef = React.useRef<Dialog>(null)
const loginInputAccountRef = React.useRef<TextField>(null)
const loginInputPasswordRef = React.useRef<TextField>(null)
const registerDialogRef = React.useRef<Dialog>(null)
const registerInputUserNameRef = React.useRef<TextField>(null)
const registerInputNickNameRef = React.useRef<TextField>(null)
const registerInputPasswordRef = React.useRef<TextField>(null)
const myProfileDialogRef = React.useRef<Dialog>(null)
const openMyProfileDialogButtonRef = React.useRef<HTMLElement>(null)
useEventListener(openMyProfileDialogButtonRef, 'click', (_event) => {
myProfileDialogRef.current!.open = true
})
const addContactDialogRef = React.useRef<Dialog>(null)
const createGroupDialogRef = React.useRef<Dialog>(null)
const chatInfoDialogRef = React.useRef<Dialog>(null)
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
const [myUserProfileCache, setMyUserProfileCache] = React.useState(null as unknown as User)
const [isShowChatFragment, setIsShowChatFragment] = React.useState(false)
const [currentChatId, setCurrentChatId] = React.useState('')
const [sharedFavouriteChats, setSharedFavouriteChats] = React.useState<Chat[]>([])
const chatFragmentDialogRef = React.useRef<Dialog>(null)
React.useEffect(() => {
const shadow = chatFragmentDialogRef.current!.shadowRoot as ShadowRoot
const panel = shadow.querySelector(".panel") as HTMLElement
panel.style.padding = '0'
panel.style.color = 'inherit'
panel.style.backgroundColor = 'rgb(var(--mdui-color-background))'
panel.style.setProperty('--mdui-color-background', 'inherit')
const body = shadow.querySelector(".body") as HTMLElement
body.style.height = '100%'
body.style.display = 'flex'
})
useAsyncEffect(async () => {
Client.connect()
const re = await Client.auth(data.access_token || "")
if (re.code == 401)
loginDialogRef.current!.open = true
else if (re.code != 200) {
if (checkApiSuccessOrSncakbar(re, "验证失败")) return
} else if (re.code == 200) {
setMyUserProfileCache(Client.myUserProfile as User)
}
})
function openChatInfoDialog(chat: Chat) {
setChatInfo(chat)
chatInfoDialogRef.current!.open = true
}
function openChatFragment(chatId: string) {
setCurrentChatId(chatId)
setIsShowChatFragment(true)
}
async function openUserInfoDialog(user: User | string) {
const re = await Client.invoke("Chat.getIdForPrivate", {
token: data.access_token,
target: typeof user == 'object' ? user.id : user,
})
if (re.code != 200) {
checkApiSuccessOrSncakbar(re, '获取对话失败')
return
}
openChatInfoDialog(re.data as Chat)
/* if (typeof user == 'object') {
setUserInfo(user)
} else {
setUserInfo(await DataCaches.getUserProfile(user))
}
userProfileDialogRef.current!.open = true */
}
// deno-lint-ignore no-window
window.openUserInfoDialog = openUserInfoDialog
// deno-lint-ignore no-window
window.openChatInfoDialog = openChatInfoDialog
return (
<div style={{
display: "flex",
position: 'relative',
flexDirection: 'column',
width: 'var(--whitesilk-window-width)',
height: 'var(--whitesilk-window-height)',
}}>
<mdui-dialog fullscreen open={isShowChatFragment} ref={chatFragmentDialogRef}>
{
// 聊天页面
}
<div id="ChatFragment" style={{
width: '100%',
height: '100%',
}}>
<ChatFragment
showReturnButton={true}
openUserInfoDialog={openUserInfoDialog}
onReturnButtonClicked={() => setIsShowChatFragment(false)}
key={currentChatId}
openChatInfoDialog={openChatInfoDialog}
target={currentChatId} />
</div>
</mdui-dialog>
<LoginDialog
loginDialogRef={loginDialogRef}
loginInputAccountRef={loginInputAccountRef}
loginInputPasswordRef={loginInputPasswordRef}
registerDialogRef={registerDialogRef} />
<RegisterDialog
registerDialogRef={registerDialogRef}
registerInputUserNameRef={registerInputUserNameRef}
registerInputNickNameRef={registerInputNickNameRef}
registerInputPasswordRef={registerInputPasswordRef}
loginInputAccountRef={loginInputAccountRef}
loginInputPasswordRef={loginInputPasswordRef} />
<ChatInfoDialog
chatInfoDialogRef={chatInfoDialogRef as any}
sharedFavouriteChats={sharedFavouriteChats}
openChatFragment={(id) => {
setCurrentChatId(id)
setIsShowChatFragment(true)
}}
chat={chatInfo} />
<MyProfileDialog
myProfileDialogRef={myProfileDialogRef as any}
user={myUserProfileCache} />
<AddContactDialog
addContactDialogRef={addContactDialogRef} />
<CreateGroupDialog
createGroupDialogRef={createGroupDialogRef} />
<mdui-top-app-bar style={{
position: 'sticky',
marginTop: '3px',
marginRight: '6px',
marginLeft: '15px',
top: '0px',
}}>
<mdui-top-app-bar-title>{
({
Recents: "最近对话",
Contacts: "收藏对话",
AllChats: "所有对话",
})[navigationItemSelected]
}</mdui-top-app-bar-title>
<div style={{
flexGrow: 1,
}}></div>
<mdui-button-icon icon="refresh" onClick={() => {
EventBus.emit('RecentsList.updateRecents')
EventBus.emit('ContactsList.updateContacts')
EventBus.emit('AllChatsList.updateAllChats')
}} style={{
margin: "0",
}}></mdui-button-icon>
<mdui-dropdown trigger="hover">
<mdui-button-icon icon="add" slot="trigger"></mdui-button-icon>
<mdui-menu>
<mdui-menu-item icon="person_add" onClick={() => addContactDialogRef.current!.open = true}></mdui-menu-item>
<mdui-menu-item icon="group_add" onClick={() => createGroupDialogRef.current!.open = true}></mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
<mdui-button-icon icon="settings"></mdui-button-icon>
<mdui-button-icon>
<Avatar src={getUrlForFileByHash(myUserProfileCache?.avatar_file_hash)} text={myUserProfileCache?.nickname} avatarRef={openMyProfileDialogButtonRef} />
</mdui-button-icon>
</mdui-top-app-bar>
{
// 侧边列表
}
<div style={{
display: 'flex',
height: 'calc(100% - 80px - 67px)',
width: '100%',
}} id="SideBar">
{
// 最近聊天
<RecentsList
openChatFragment={(id) => {
setCurrentChatId(id)
setIsShowChatFragment(true)
}}
display={navigationItemSelected == "Recents"}
currentChatId={currentChatId} />
}
{
// 最近聊天
<AllChatsList
openChatInfoDialog={openChatInfoDialog}
display={navigationItemSelected == "AllChats"}
currentChatId={currentChatId} />
}
{
// 對話列表
<ContactsList
currentChatId={currentChatId}
openChatInfoDialog={openChatInfoDialog}
setSharedFavouriteChats={setSharedFavouriteChats}
addContactDialogRef={addContactDialogRef as any}
createGroupDialogRef={createGroupDialogRef as any}
display={navigationItemSelected == "Contacts"} />
}
</div>
<mdui-navigation-bar label-visibility="selected" value="Recents" ref={navigationBarRef} style={{
position: 'sticky',
bottom: '0',
}}>
<mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-bar-item>
<mdui-navigation-bar-item icon="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>
)
}

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

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

@@ -1,18 +0,0 @@
import * as React from 'react'
import { $, TextField } from "mdui"
interface Args extends React.HTMLAttributes<TextField & HTMLElement> {
}
export default function TextFieldCustom({ ...prop }: Args) {
// deno-lint-ignore no-explicit-any
const textField = React.useRef<any>(null)
React.useEffect(() => {
const shadow = (textField.current as TextField).shadowRoot
// $(shadow).find('textarea')
})
return <mdui-text-field {...prop} ref={textField}></mdui-text-field>
}

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

@@ -1,4 +1,4 @@
import openImageViewer from "../openImageViewer.ts"
import openImageViewer from "../../utils/openImageViewer.ts"
import { $ } from 'mdui/jq'
@@ -56,37 +56,3 @@ customElements.define('chat-image', class extends HTMLElement {
this.update()
}
})
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)

View File

@@ -1,7 +1,5 @@
import { $ } from 'mdui'
import DataCaches from "../../api/DataCaches.ts"
import { snackbar } from "../snackbar.ts"
import showSnackbar from "../../utils/showSnackbar.ts";
customElements.define('chat-mention', class extends HTMLElement {
declare link: HTMLAnchorElement
static observedAttributes = ['user-id']
@@ -32,20 +30,18 @@ customElements.define('chat-mention', class extends HTMLElement {
const text = $(this).attr('text')
this.link.style.fontStyle = ''
if (chatId) {
const chat = await DataCaches.getChatInfo(chatId)
this.link.textContent = chat?.title
this.link.onclick = (e) => {
e.stopPropagation()
// deno-lint-ignore no-window
window.openChatInfoDialog(chat)
}
} else if (userId) {
const user = await DataCaches.getUserProfile(userId)
this.link.textContent = user?.nickname
this.link.onclick = (e) => {
e.stopPropagation()
// deno-lint-ignore no-window
window.openUserInfoDialog(user)
}
}
@@ -55,9 +51,8 @@ customElements.define('chat-mention', class extends HTMLElement {
this.link.style.fontStyle = 'italic'
this.link.onclick = (e) => {
e.stopPropagation()
snackbar({
showSnackbar({
message: "该提及没有指定用户或者对话!",
placement: 'top',
})
}
}

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

@@ -1,695 +0,0 @@
import { Tab, TextField } from "mdui"
import { $ } from "mdui/jq"
import useEventListener from "../useEventListener.ts"
import Element_Message from "./Message.tsx"
import MessageContainer from "./MessageContainer.tsx"
import * as React from 'react'
import Client from "../../api/Client.ts"
import Message from "../../api/client_data/Message.ts"
import Chat from "../../api/client_data/Chat.ts"
import data from "../../Data.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import useAsyncEffect from "../useAsyncEffect.ts"
import * as marked from 'marked'
import DOMPurify from 'dompurify'
import randomUUID from "../../randomUUID.ts"
import EventBus from "../../EventBus.ts"
import User from "../../api/client_data/User.ts"
import PreferenceLayout from '../preference/PreferenceLayout.tsx'
import PreferenceHeader from '../preference/PreferenceHeader.tsx'
import PreferenceStore from '../preference/PreferenceStore.ts'
import SwitchPreference from '../preference/SwitchPreference.tsx'
import SelectPreference from '../preference/SelectPreference.tsx'
import TextFieldPreference from '../preference/TextFieldPreference.tsx'
import Preference from '../preference/Preference.tsx'
import GroupSettings from "../../api/client_data/GroupSettings.ts"
import PreferenceUpdater from "../preference/PreferenceUpdater.ts"
import SystemMessage from "./SystemMessage.tsx"
import JoinRequestsList from "./JoinRequestsList.tsx"
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
import escapeHTML from "../../escapeHtml.ts"
import GroupMembersList from "./GroupMembersList.tsx"
interface Args extends React.HTMLAttributes<HTMLElement> {
target: string
showReturnButton?: boolean
openChatInfoDialog: (chat: Chat) => void
onReturnButtonClicked?: () => void
openUserInfoDialog: (user: User | string) => void
}
const sanitizeConfig = {
ALLOWED_TAGS: [
"chat-image",
"chat-video",
"chat-file",
'chat-text',
"chat-link",
'chat-mention',
],
ALLOWED_ATTR: [
'underline',
'em',
'src',
'alt',
'href',
'name',
'user-id',
'chat-id',
],
}
const markedInstance = new marked.Marked({
renderer: {
text({ text }) {
return `<chat-text>${escapeHTML(text)}</chat-text>`
},
em({ text }) {
return `<chat-text em="true">${escapeHTML(text)}</chat-text>`
},
heading({ tokens, depth: _depth }) {
const text = this.parser.parseInline(tokens)
return `<chat-text>${escapeHTML(text)}</chat-text>`
},
image({ text, href }) {
const type = /^(Video|File|UserMention|ChatMention)=.*/.exec(text)?.[1]
const fileType = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
if (fileType != null && /tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1])
return ({
Image: `<chat-image src="${url}" alt="${escapeHTML(text)}"></chat-image>`,
Video: `<chat-video src="${url}"></chat-video>`,
File: `<chat-file href="${url}" name="${escapeHTML(/^Video|File=(.*)/.exec(text)?.[1] || 'Unnamed file')}"></chat-file>`,
})?.[fileType] || ``
} else
switch (type) {
case "UserMention":
return `<chat-mention user-id="${escapeHTML(/^tws:\/\/user\?id=(.*)/.exec(href)?.[1] || '')}" text="${escapeHTML(/^UserMention=(.*)/.exec(text)?.[1] || '')}">PH</chat-mention>`
case "ChatMention":
return `<chat-mention chat-id="${escapeHTML(/^tws:\/\/chat\?id=(.*)/.exec(href)?.[1] || '')}" text="${escapeHTML(/^ChatMention=(.*)/.exec(text)?.[1] || '')}">PH</chat-mention>`
}
return `<chat-text em="true">${escapeHTML(`[无效数据 (<${text}>=${href})]`)}</chat-text>`
},
}
})
export default function ChatFragment({ target, showReturnButton, onReturnButtonClicked, openChatInfoDialog, openUserInfoDialog, ...props }: Args) {
const [messagesList, setMessagesList] = React.useState([] as Message[])
const [chatInfo, setChatInfo] = React.useState({
title: '加载中...',
is_member: true,
is_admin: true,
} as Chat)
const [tabItemSelected, setTabItemSelected] = React.useState('None')
const tabRef = React.useRef<Tab>(null)
const chatPanelRef = React.useRef<HTMLElement>(null)
useEventListener(tabRef, 'change', () => {
tabRef.current != null && setTabItemSelected(tabRef.current!.value as string)
})
const containerTabRef = React.useRef<Tab>(null)
React.useEffect(() => {
$(containerTabRef.current!.shadowRoot).append(`<style>.container::after { height: 0 !important; }</style>`)
$(tabRef.current!.shadowRoot).append(`<style>.container::after { height: 0 !important; }</style>`)
}, [target])
async function getChatInfoAndInit() {
setMessagesList([])
page.current = 0
const re = await Client.invoke('Chat.getInfo', {
token: data.access_token,
target: target,
})
if (re.code != 200)
return target != '' && checkApiSuccessOrSncakbar(re, "获取对话信息失败")
const chatInfo = re.data as Chat
setChatInfo(chatInfo)
if (chatInfo.is_member)
await loadMore()
setTabItemSelected(chatInfo.is_member ? "Chat" : "RequestJoin")
if (re.data!.type == 'group') {
groupPreferenceStore.setState(chatInfo.settings as GroupSettings)
}
setTimeout(() => {
chatPanelRef.current!.scrollTo({
top: 10000000000,
behavior: "smooth",
})
}, 500)
}
useAsyncEffect(getChatInfoAndInit, [target])
const page = React.useRef(0)
async function loadMore() {
const re = await Client.invoke("Chat.getMessageHistory", {
token: data.access_token,
target,
page: page.current,
})
if (checkApiSuccessOrSncakbar(re, "拉取对话记录失败"))
return
const returnMsgs = (re.data!.messages as Message[]).reverse()
page.current++
if (returnMsgs.length == 0) {
setShowNoMoreMessagesTip(true)
setTimeout(() => setShowNoMoreMessagesTip(false), 1000)
return
}
const oldest = messagesList[0]
setMessagesList(returnMsgs.concat(messagesList))
oldest && setTimeout(() => chatPanelRef.current!.scrollTo({ top: $(`#chat_${target}_message_${oldest.id}`).get(0).offsetTop }), 200)
}
React.useEffect(() => {
interface OnMessageData {
chat: string
msg: Message
}
function callback(data: unknown) {
const { chat, msg } = (data as OnMessageData)
if (target == chat) {
setMessagesList(messagesList.concat([msg]))
if ((chatPanelRef.current!.scrollHeight - chatPanelRef.current!.scrollTop - chatPanelRef.current!.clientHeight) < 130)
setTimeout(() => chatPanelRef.current!.scrollTo({
top: 10000000000,
behavior: "smooth",
}), 100)
}
}
Client.on('Client.onMessage', callback)
return () => {
Client.off('Client.onMessage', callback)
}
})
const inputRef = React.useRef<TextField>(null)
const [showLoadingMoreMessagesTip, setShowLoadingMoreMessagesTip] = React.useState(false)
const [showNoMoreMessagesTip, setShowNoMoreMessagesTip] = React.useState(false)
const [isMessageSending, setIsMessageSending] = React.useState(false)
const cachedFiles = React.useRef({} as { [fileName: string]: ArrayBuffer })
const cachedFileNamesCount = React.useRef({} as { [fileName: string]: number })
async function sendMessage() {
let text = inputRef.current!.value
if (text.trim() == '') return
const sendingFilesSnackbar = snackbar({
message: `发送消息到 [${chatInfo.title}]...`,
placement: 'top',
autoCloseDelay: 0,
})
let i = 1
let i2 = 0
const sendingFilesSnackbarId = setInterval(() => {
const len = Object.keys(cachedFiles.current).filter((fileName) => text.indexOf(fileName)).length
sendingFilesSnackbar.textContent = i2 == len ? `发送消息到 [${chatInfo.title}]... (${i}s)` : `上传第 ${i2}/${len} 文件到 [${chatInfo.title}]... (${i}s)`
i++
}, 1000)
function endSendingSnack() {
clearTimeout(sendingFilesSnackbarId)
sendingFilesSnackbar.open = false
}
try {
setIsMessageSending(true)
for (const fileName of Object.keys(cachedFiles.current)) {
if (text.indexOf(fileName) != -1) {
const re = await Client.uploadFileLikeApi(
fileName,
cachedFiles.current[fileName]
)
if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上传失败`)) {
endSendingSnack()
return setIsMessageSending(false)
}
text = text.replaceAll('(' + fileName + ')', '(tws://file?hash=' + re.data!.file_hash as string + ')')
i2++
}
}
const re = await Client.invoke("Chat.sendMessage", {
token: data.access_token,
target,
text,
}, 5000)
if (checkApiSuccessOrSncakbar(re, "发送失败")) {
endSendingSnack()
return setIsMessageSending(false)
}
inputRef.current!.value = ''
cachedFiles.current = {}
} catch (e) {
snackbar({
message: '发送失败: ' + (e as Error).message,
placement: 'top',
})
}
setIsMessageSending(false)
endSendingSnack()
}
const attachFileInputRef = React.useRef<HTMLInputElement>(null)
const uploadChatAvatarRef = React.useRef<HTMLInputElement>(null)
function insertText(text: string) {
const input = inputRef.current!.shadowRoot!.querySelector('[part=input]') as HTMLTextAreaElement
inputRef.current!.value = input.value!.substring(0, input.selectionStart as number) + text + input.value!.substring(input.selectionEnd as number, input.value.length)
}
async function addFile(type: string, name_: string, data: Blob | Response) {
let name = name_
while (cachedFiles.current[name] != null) {
name = name_ + '_' + cachedFileNamesCount.current[name]
cachedFileNamesCount.current[name]++
}
cachedFiles.current[name] = await data.arrayBuffer()
cachedFileNamesCount.current[name] = 1
if (type.startsWith('image/'))
insertText(`![图片](${name})`)
else if (type.startsWith('video/'))
insertText(`![Video=${name}](${name})`)
else
insertText(`![File=${name}](${name})`)
}
useEventListener(attachFileInputRef, 'change', (_e) => {
const files = attachFileInputRef.current!.files as unknown as File[]
if (files?.length == 0) return
for (const file of files) {
addFile(file.type, file.name, file)
}
attachFileInputRef.current!.value = ''
})
useEventListener(uploadChatAvatarRef, 'change', async (_e) => {
const file = uploadChatAvatarRef.current!.files?.[0] as File
if (file == null) return
let re = await Client.uploadFileLikeApi(
'avatar',
file
)
if (checkApiSuccessOrSncakbar(re, "上传失败")) return
const hash = re.data!.file_hash
re = await Client.invoke("Chat.setAvatar", {
token: data.access_token,
target: target,
file_hash: hash,
})
uploadChatAvatarRef.current!.value = ''
if (checkApiSuccessOrSncakbar(re, "修改失败")) return
snackbar({
message: "修改成功 (刷新页面以更新)",
placement: "top",
})
})
const groupPreferenceStore = new PreferenceStore<GroupSettings>()
groupPreferenceStore.setOnUpdate(async (value, oldvalue) => {
const re = await Client.invoke("Chat.updateSettings", {
token: data.access_token,
target,
settings: value,
})
if (checkApiSuccessOrSncakbar(re, "更新设定失败")) return groupPreferenceStore.setState(oldvalue)
})
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
}} {...props}>
<mdui-tabs ref={containerTabRef} style={{
position: 'sticky',
display: "flex",
flexDirection: "column",
height: "100%",
}}>
{
showReturnButton && <mdui-button-icon icon="arrow_back" onClick={onReturnButtonClicked} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
}
<mdui-tabs ref={tabRef} value={tabItemSelected} style={{
position: 'sticky',
display: "flex",
flexDirection: "column",
height: "100%",
}}>
{
chatInfo.is_member ? <>
<mdui-tab value="Chat">{chatInfo.title}</mdui-tab>
{chatInfo.type == 'group' && chatInfo.is_admin && <mdui-tab value="NewMemberRequests"></mdui-tab>}
{chatInfo.type == 'group' && <mdui-tab value="GroupMembers"></mdui-tab>}
</>
: <mdui-tab value="RequestJoin">{chatInfo.title}</mdui-tab>
}
{chatInfo.type == 'group' && <mdui-tab value="Settings"></mdui-tab>}
<mdui-tab value="None" style={{ display: 'none' }}></mdui-tab>
</mdui-tabs>
<div style={{
flexGrow: '1',
}}></div>
<mdui-button-icon icon="refresh" onClick={() => getChatInfoAndInit()} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
<mdui-button-icon icon="info" onClick={() => openChatInfoDialog(chatInfo)} style={{
alignSelf: 'center',
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
<mdui-tab-panel slot="panel" value="RequestJoin" style={{
display: tabItemSelected == "RequestJoin" ? "flex" : "none",
flexDirection: "column",
height: "100%",
justifyContent: 'center',
alignItems: 'center',
}}>
<div>
<mdui-button disabled={!groupPreferenceStore.state.allow_new_member_join} onClick={async () => {
const re = await Client.invoke("Chat.sendJoinRequest", {
token: data.access_token,
target: target,
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "发送加入请求失败")
snackbar({
message: '发送成功!',
placement: 'top',
})
}}></mdui-button>
</div>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="Chat" ref={chatPanelRef} style={{
display: tabItemSelected == "Chat" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}} onScroll={async (e) => {
if (!chatInfo.is_member) return
const scrollTop = (e.target as HTMLDivElement).scrollTop
if (scrollTop == 0 && !showLoadingMoreMessagesTip) {
setShowNoMoreMessagesTip(false)
setShowLoadingMoreMessagesTip(true)
await loadMore()
setShowLoadingMoreMessagesTip(false)
}
}}>
<div style={{
display: 'flex',
justifyContent: "center",
paddingTop: "15px",
}}>
<div style={{
display: showLoadingMoreMessagesTip ? 'flex' : 'none',
}}>
<mdui-circular-progress style={{
width: '30px',
height: '30px',
}}></mdui-circular-progress>
<span style={{
alignSelf: 'center',
paddingLeft: '12px',
}}>...</span>
</div>
<div style={{
display: showNoMoreMessagesTip ? undefined : 'none',
alignSelf: 'center',
}}>
~
</div>
</div>
<MessageContainer style={{
paddingTop: "15px",
flexGrow: '1',
}}>
{
(() => {
let date = new Date(0)
return messagesList.map((msg) => {
const lastDate = date
date = new Date(msg.time)
const msgElement = msg.user_id == null ? <SystemMessage><div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
ALLOWED_ATTR: [
...sanitizeConfig.ALLOWED_ATTR,
],
ALLOWED_TAGS: [
...sanitizeConfig.ALLOWED_TAGS,
],
})
}} /></SystemMessage> : <Element_Message
rawData={msg.text}
renderHTML={DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig)}
message={msg}
key={msg.id}
slot="trigger"
id={`chat_${target}_message_${msg.id}`}
userId={msg.user_id}
openUserInfoDialog={openUserInfoDialog} />
return (
<>
{
msg.user_id != null &&
(date.getMinutes() != lastDate.getMinutes() || date.getDate() != lastDate.getDate() || date.getMonth() != lastDate.getMonth() || date.getFullYear() != lastDate.getFullYear())
&& <mdui-tooltip content={`${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`}>
<div style={{
fontSize: '87%',
marginTop: '10px',
}}>
{
(date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}` : '')
+ `${date.getMonth() + 1}`
+ `${date.getDate()}`
+ ` ${date.getHours()}:${date.getMinutes()}`
}
</div>
</mdui-tooltip>
}
{
msgElement
}
</>
)
})
})()
}
</MessageContainer>
{
// 输入框
}
<div style={{
display: 'flex',
alignItems: 'center',
paddingBottom: '2px',
paddingTop: '0.1rem',
position: 'sticky',
bottom: '0',
paddingLeft: '5px',
paddingRight: '4px',
backgroundColor: 'rgb(var(--mdui-color-surface))',
}} onDrop={(e) => {
function getFileNameOrRandom(urlString: string) {
const url = new URL(urlString)
let filename = url.pathname.substring(url.pathname.lastIndexOf('/') + 1).trim()
if (filename == '')
filename = 'file_' + randomUUID()
return filename
}
if (e.dataTransfer.items.length > 0) {
// 基于当前的实现, 浏览器不会读取文件的字节流来确定其媒体类型, 其根据文件扩展名进行假设
// https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/type
for (const item of e.dataTransfer.items) {
if (item.type == 'text/uri-list') {
item.getAsString(async (url) => {
try {
// 即便是 no-cors 還是殘廢, 因此暫時沒有什麽想法
const re = await fetch(url)
const type = re.headers.get("Content-Type")
if (type && re.ok)
addFile(type as string, getFileNameOrRandom(url), re)
} catch (e) {
snackbar({
message: '无法解析链接: ' + (e as Error).message,
placement: 'top',
})
}
})
} else if (item.kind == 'file') {
e.preventDefault()
const file = item.getAsFile() as File
addFile(item.type, file.name, file)
}
}
}
}}>
<mdui-text-field variant="outlined" placeholder="(。・ω・。)" autosize ref={inputRef as any} max-rows={6} onChange={() => {
if (inputRef.current?.value.trim() == '')
cachedFiles.current = {}
}} onKeyDown={(event) => {
if (event.ctrlKey && event.key == 'Enter')
sendMessage()
}} onPaste={(event) => {
for (const item of event.clipboardData.items) {
if (item.kind == 'file') {
event.preventDefault()
const file = item.getAsFile() as File
addFile(item.type, file.name, file)
}
}
}} style={{
marginRight: '10px',
marginTop: '3px',
marginBottom: '3px',
}}></mdui-text-field>
<mdui-button-icon slot="end-icon" icon="attach_file" style={{
marginRight: '6px',
}} onClick={() => {
attachFileInputRef.current!.click()
}}></mdui-button-icon>
<mdui-button-icon icon="send" style={{
marginRight: '7px',
}} onClick={() => sendMessage()} loading={isMessageSending}></mdui-button-icon>
<div style={{
display: 'none'
}}>
<input accept="*/*" type="file" name="添加文件" multiple ref={attachFileInputRef}></input>
</div>
</div>
</mdui-tab-panel>
{
chatInfo.type == 'group' && <mdui-tab-panel slot="panel" value="GroupMembers" style={{
display: tabItemSelected == "GroupMembers" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
<GroupMembersList chat={chatInfo} />
</mdui-tab-panel>
}
{
chatInfo.type == 'group' && <mdui-tab-panel slot="panel" value="NewMemberRequests" style={{
display: tabItemSelected == "NewMemberRequests" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
{chatInfo.is_admin && <JoinRequestsList chat={chatInfo} />}
</mdui-tab-panel>
}
<mdui-tab-panel slot="panel" value="Settings" style={{
display: tabItemSelected == "Settings" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
<div style={{
display: 'none'
}}>
<input accept="image/*" type="file" name="上传对话头像" ref={uploadChatAvatarRef}></input>
</div>
{
chatInfo.type == 'group' && <PreferenceLayout>
<PreferenceUpdater.Provider value={groupPreferenceStore.createUpdater()}>
<PreferenceHeader
title="群组资料" />
<Preference
title="上传新的头像"
icon="image"
disabled={!chatInfo.is_admin}
onClick={() => {
uploadChatAvatarRef.current!.click()
}} />
<TextFieldPreference
title="设置群名称"
icon="edit"
id="group_title"
state={groupPreferenceStore.state.group_title || ''}
disabled={!chatInfo.is_admin} />
<TextFieldPreference
title="设置群别名"
icon="edit"
id="group_name"
description="以便于添加, 可留空"
state={groupPreferenceStore.state.group_name || ''}
disabled={!chatInfo.is_admin} />
<PreferenceHeader
title="入群设定" />
<SwitchPreference
title="允许入群"
icon="person_add"
id="allow_new_member_join"
disabled={!chatInfo.is_admin}
state={groupPreferenceStore.state.allow_new_member_join || false} />
{/* <SwitchPreference
title="允许成员邀请"
description="目前压根没有这项功能, 甚至还不能查看成员列表, 以后再说吧"
id="allow_new_member_from_invitation"
icon="_"
disabled={true || !chatInfo.is_admin}
state={groupPreferenceStore.state.allow_new_member_from_invitation || false} />
<SelectPreference
title="入群验证方式"
icon="_"
id="new_member_join_method"
selections={{
disabled: "无需验证",
allowed_by_admin: "只需要管理员批准 (WIP)",
answered_and_allowed_by_admin: "需要回答问题并获得管理员批准 (WIP)",
}}
disabled={!chatInfo.is_admin || !groupPreferenceStore.state.allow_new_member_join}
state={groupPreferenceStore.state.new_member_join_method || 'disabled'} />
{
groupPreferenceStore.state.new_member_join_method == 'answered_and_allowed_by_admin'
&& <TextFieldPreference
title="设置问题"
icon="_"
id="answered_and_allowed_by_admin_question"
description="WIP"
state={groupPreferenceStore.state.answered_and_allowed_by_admin_question || ''}
disabled={true || !chatInfo.is_admin} />
} */}
</PreferenceUpdater.Provider>
</PreferenceLayout>
}
{
chatInfo.type == 'private' && (
<div>
</div>
)
}
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="None" style={{
display: tabItemSelected == "None" ? "flex" : "none",
flexDirection: "column",
height: "100%",
}}>
<div style={{
display: 'flex',
width: '100%',
height: '100%',
alignItems: "center",
justifyContent: "center",
}}>
<mdui-circular-progress></mdui-circular-progress>
</div>
</mdui-tab-panel>
</mdui-tabs>
</div>
)
}

View File

@@ -1,81 +0,0 @@
import { TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import React from "react"
import useAsyncEffect from "../useAsyncEffect.ts"
import Client from "../../api/Client.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import data from "../../Data.ts"
import EventBus from "../../EventBus.ts"
import GroupMembersListItem from "./GroupMembersListItem.tsx"
import User from "../../api/client_data/User.ts"
import Chat from "../../api/client_data/Chat.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
chat: Chat
}
export default function GroupMembersList({
chat,
...props
}: Args) {
const target = chat.id
const searchRef = React.useRef<HTMLElement>(null)
const [searchText, setSearchText] = React.useState('')
const [groupMembers, setGroupMembers] = React.useState<User[]>([])
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
})
React.useEffect(() => {
async function updateMembers() {
const re = await Client.invoke("Chat.getMembers", {
token: data.access_token,
target: target,
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "获取群组成员列表失败")
setGroupMembers(re.data!.members as User[])
}
updateMembers()
EventBus.on('GroupMembersList.updateMembers', () => updateMembers())
const id = setInterval(() => updateMembers(), 15 * 1000)
return () => {
clearInterval(id)
EventBus.off('GroupMembersList.updateMembers')
}
}, [target])
return <mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
paddingLeft: '10px',
height: '100%',
width: '100%',
}} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
marginTop: '5px',
marginBottom: '13px',
}}></mdui-text-field>
<mdui-list-item rounded style={{
width: '100%',
marginBottom: '15px',
}} icon="refresh" onClick={() => EventBus.emit('GroupMembersList.updateMembers')}></mdui-list-item>
{
groupMembers.filter((user) =>
searchText == '' ||
user.nickname.includes(searchText) ||
user.username?.includes(searchText) ||
user.id.includes(searchText)
).map((v) =>
<GroupMembersListItem
key={v.id}
chat={chat}
user={v} />
)
}
</mdui-list>
}

View File

@@ -1,84 +0,0 @@
import { $, dialog } from "mdui"
import Avatar from "../Avatar.tsx"
import React from 'react'
import User from "../../api/client_data/User.ts"
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
import Client from "../../api/Client.ts"
import data from "../../Data.ts"
import Chat from "../../api/client_data/Chat.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import EventBus from "../../EventBus.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
user: User
chat: Chat
}
export default function GroupMembersListItem({ user, chat }: Args) {
const { id, nickname, avatar_file_hash } = user
const itemRef = React.useRef<HTMLElement>(null)
return (
<mdui-list-item rounded style={{
marginTop: '3px',
marginBottom: '3px',
}} ref={itemRef} onClick={() => {
// deno-lint-ignore no-window
window.openUserInfoDialog(user)
}}>
{nickname}
<Avatar src={getUrlForFileByHash(avatar_file_hash)} text={nickname} slot="icon" />
<div slot="end-icon">
<mdui-button-icon icon="delete" onClick={(e) => {
e.stopPropagation()
dialog({
headline: "移除群组成员",
description: `确定要移除 ${nickname} 吗?`,
closeOnEsc: true,
closeOnOverlayClick: true,
actions: [
{
text: "取消",
onClick: () => {
return true
},
},
{
text: "确定",
onClick: () => {
; (async () => {
const re = await Client.invoke("Chat.removeMembers", {
token: data.access_token,
chat_id: chat.id,
user_ids: [
id
],
})
if (re.code != 200)
checkApiSuccessOrSncakbar(re, "移除群组成员失败")
EventBus.emit('GroupMembersList.updateMembers')
snackbar({
message: `已移除 ${nickname}`,
placement: "top",
/* action: "撤销操作",
onActionClick: async () => {
const re = await Client.invoke("User.addContacts", {
token: data.access_token,
targets: ls,
})
if (re.code != 200)
checkApiSuccessOrSncakbar(re, "恢复所选收藏失败")
EventBus.emit('ContactsList.updateContacts')
} */
})
})()
return true
},
}
],
})
}}></mdui-button-icon>
</div>
</mdui-list-item>
)
}

View File

@@ -1,106 +0,0 @@
import { TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import React from "react"
import Client from "../../api/Client.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import data from "../../Data.ts"
import EventBus from "../../EventBus.ts"
import JoinRequest from "../../api/client_data/JoinRequest.ts"
import JoinRequestsListItem from "./JoinRequestsListItem.tsx"
import Chat from "../../api/client_data/Chat.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
chat: Chat
}
export default function GroupMembersList({
chat,
...props
}: Args) {
const target = chat.id
const searchRef = React.useRef<HTMLElement>(null)
const [searchText, setSearchText] = React.useState('')
const [updateJoinRequests, setUpdateJoinRequests] = React.useState<JoinRequest[]>([])
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
})
React.useEffect(() => {
async function updateJoinRequests() {
const re = await Client.invoke("Chat.getJoinRequests", {
token: data.access_token,
target: target,
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "获取加入请求列表失败")
setUpdateJoinRequests(re.data!.join_requests as JoinRequest[])
}
updateJoinRequests()
EventBus.on('JoinRequestsList.updateJoinRequests', () => updateJoinRequests())
const id = setInterval(() => updateJoinRequests(), 15 * 1000)
return () => {
clearInterval(id)
EventBus.off('JoinRequestsList.updateJoinRequests')
}
}, [target])
async function removeJoinRequest(userId: string) {
const re = await Client.invoke("Chat.processJoinRequest", {
token: data.access_token,
chat_id: target,
user_id: userId,
action: 'remove',
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "删除加入请求失败")
EventBus.emit('JoinRequestsList.updateJoinRequests')
}
async function acceptJoinRequest(userId: string) {
const re = await Client.invoke("Chat.processJoinRequest", {
token: data.access_token,
chat_id: target,
user_id: userId,
action: 'accept',
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, "通过加入请求失败")
EventBus.emit('JoinRequestsList.updateJoinRequests')
}
return <mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
paddingLeft: '10px',
height: '100%',
width: '100%',
}} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
marginTop: '5px',
marginBottom: '13px',
}}></mdui-text-field>
<mdui-list-item rounded style={{
width: '100%',
marginBottom: '15px',
}} icon="refresh" onClick={() => EventBus.emit('JoinRequestsList.updateJoinRequests')}></mdui-list-item>
{
updateJoinRequests.filter((joinRequest) =>
searchText == '' ||
joinRequest.title.includes(searchText) ||
joinRequest.reason?.includes(searchText) ||
joinRequest.user_id.includes(searchText)
).map((v) =>
<JoinRequestsListItem
key={v.user_id}
acceptJoinRequest={acceptJoinRequest}
removeJoinRequest={removeJoinRequest}
joinRequest={v} />
)
}
</mdui-list>
}

View File

@@ -1,40 +0,0 @@
import { $ } from "mdui/jq"
import Avatar from "../Avatar.tsx"
import React from 'react'
import JoinRequest from "../../api/client_data/JoinRequest.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
joinRequest: JoinRequest
acceptJoinRequest: (userId: string) => any
removeJoinRequest: (userId: string) => any
}
export default function JoinRequestsListItem({ joinRequest, acceptJoinRequest, removeJoinRequest }: Args) {
const { user_id, title, avatar, reason } = joinRequest
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',
}} ref={itemRef}>
{title}
<Avatar src={avatar} text={title} slot="icon" />
<span slot="description"
style={{
width: "100%",
display: "inline-block",
whiteSpace: "nowrap", /* 禁止换行 */
overflow: "hidden", /* 隐藏溢出内容 */
textOverflow: "ellipsis", /* 显示省略号 */
}}>: {reason || "无"}</span>
<div slot="end-icon">
<mdui-button-icon icon="check" onClick={() => acceptJoinRequest(user_id)}></mdui-button-icon>
<mdui-button-icon icon="delete" onClick={() => removeJoinRequest(user_id)}></mdui-button-icon>
</div>
</mdui-list-item>
)
}

View File

@@ -1,220 +0,0 @@
import { Dropdown, Dialog, dialog } from "mdui"
import { $ } from "mdui/jq"
import Client from "../../api/Client.ts"
import Data_Message from "../../api/client_data/Message.ts"
import DataCaches from "../../api/DataCaches.ts"
import Avatar from "../Avatar.tsx"
import copyToClipboard from "../copyToClipboard.ts"
import useAsyncEffect from "../useAsyncEffect.ts"
import useEventListener from "../useEventListener.ts"
import React from "react"
import isMobileUI from "../isMobileUI.ts"
import User from "../../api/client_data/User.ts"
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
import escapeHTML from "../../escapeHtml.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
userId: string
rawData: string
renderHTML: string
message: Data_Message
openUserInfoDialog: (user: User | string) => void
}
function prettyFlatParsedMessage(html: string) {
const elements = new DOMParser().parseFromString(html, 'text/html').body.children
// 纯文本直接处理
if (elements.length == 0)
return `<chat-text-container><chat-text>${escapeHTML(html)}</chat-text></chat-text-container>`
let ls: Element[] = []
let ret = ''
// 第一个元素时, 不会被聚合在一起
let lastElementType = ''
const textElementTags = [
'chat-text',
'chat-mention',
]
function checkContinuousElement(tagName: string) {
/* console.log('shangyige ', lastElementType)
console.log("dangqian", tagName)
console.log("上一个元素的类型和当前不一致?", lastElementType != tagName)
console.log("上一个元素的类型和这个元素的类型都属于文本类型", (textElementTags.indexOf(lastElementType) != -1 && textElementTags.indexOf(tagName) != -1)) */
// 如果上一个元素的类型和当前不一致, 或者上一个元素的类型和这个元素的类型都属于文本类型 (亦或者到最后一步时) 执行
if ((lastElementType != tagName || (textElementTags.indexOf(lastElementType) != -1 && textElementTags.indexOf(tagName) != -1)) || tagName == 'LAST_CHICKEN') {
/* console.log(tagName, '进入') */
// 如果上一个元素类型为文本类型, 且当前不是文本类型时, 用文本包裹
if (textElementTags.indexOf(lastElementType) != -1) {
// 当前的文本类型不应该和上一个分离, 滚出去
if (textElementTags.indexOf(tagName) != -1) return
/* console.log(tagName, '文字和被') */
// 由于 chat-mention 不是用内部元素实现的, 因此在这个元素的生成中必须放置占位字符串
// 尽管显示上占位字符串不会显示, 但是在这里依然是会被处理的, 因为本身还是 innerHTML
// 当文本非空时, 将文字合并在一起
if (ls.map((v) => v.innerHTML).join('').trim() != '')
ret += `<chat-text-container>${ls.map((v) => v.outerHTML).join('')}</chat-text-container>`
} else
// 非文本类型元素, 各自成块
ret += ls.map((v) => v.outerHTML).join('')
ls = []
}
}
for (const e of elements) {
// 当出现非文本元素时, 将文本聚合在一起
// 如果是其他类型, 虽然也执行聚合, 但是不会有外层包裹
/* console.log("当前", e, "内容", e.innerHTML) */
checkContinuousElement(e.nodeName.toLowerCase())
ls.push(e)
lastElementType = e.nodeName.toLowerCase()
}
// 最后将剩余的转换
checkContinuousElement('LAST_CHICKEN')
return ret
}
export default function Message({ userId, rawData, renderHTML, message, openUserInfoDialog, ...props }: Args) {
const isAtRight = Client.myUserProfile?.id == userId
const [nickName, setNickName] = React.useState("")
const [avatarUrl, setAvatarUrl] = React.useState<string | undefined>("")
useAsyncEffect(async () => {
const user = await DataCaches.getUserProfile(userId)
setNickName(user.nickname)
setAvatarUrl(getUrlForFileByHash(user?.avatar_file_hash))
}, [userId])
const dropDownRef = React.useRef<Dropdown>(null)
useEventListener(dropDownRef, 'closed', () => {
setDropDownOpen(false)
})
const [isDropDownOpen, setDropDownOpen] = React.useState(false)
/* const [isUsingFullDisplay, setIsUsingFullDisplay] = React.useState(false) */
/* React.useEffect(() => {
const text = $(dropDownRef.current as HTMLElement).find('#msg').text().trim()
setIsUsingFullDisplay(text == '' || (
rawData.split("tws:\/\/file\?hash=").length == 2
&& /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim())
))
}, [renderHTML]) */
return (
<div
slot="trigger"
onContextMenu={(e) => {
if (isMobileUI()) return
e.preventDefault()
setDropDownOpen(!isDropDownOpen)
}}
onClick={(e) => {
if (!isMobileUI()) return
e.preventDefault()
setDropDownOpen(!isDropDownOpen)
}}
style={{
width: "100%",
display: "flex",
justifyContent: isAtRight ? "flex-end" : "flex-start",
flexDirection: "column"
}}
{...props}>
<div
style={{
display: "flex",
justifyContent: isAtRight ? "flex-end" : "flex-start",
}}>
{
// 发送者昵称(左)
isAtRight && <span
style={{
alignSelf: "center",
fontSize: "90%"
}}>
{nickName}
</span>
}
{
// 发送者头像
}
<Avatar
src={avatarUrl}
text={nickName}
style={{
width: "43px",
height: "43px",
margin: "11px"
}}
onClick={(e) => {
e.stopPropagation()
openUserInfoDialog(userId)
}} />
{
// 发送者昵称(右)
!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",
alignSelf: isAtRight ? "flex-end" : "flex-start",
// boxShadow: isUsingFullDisplay ? 'inherit' : 'var(--mdui-elevation-level1)',
// padding: isUsingFullDisplay ? undefined : "13px",
// paddingTop: isUsingFullDisplay ? undefined : "14px",
// backgroundColor: isUsingFullDisplay ? "inherit" : undefined
}}>
<mdui-dropdown trigger="manual" ref={dropDownRef} open={isDropDownOpen}>
<span
slot="trigger"
id="msg"
style={{
fontSize: "94%",
wordBreak: 'break-word',
display: 'flex',
flexDirection: 'column',
}}
dangerouslySetInnerHTML={{
__html: prettyFlatParsedMessage(renderHTML)
}} />
<mdui-menu onClick={(e) => {
e.stopPropagation()
setDropDownOpen(false)
}}>
<mdui-menu-item icon="content_copy" onClick={() => copyToClipboard($(dropDownRef.current as HTMLElement).find('#msg').text().trim())}></mdui-menu-item>
<mdui-menu-item icon="content_copy" onClick={() => copyToClipboard(rawData)}></mdui-menu-item>
<mdui-menu-item icon="info" onClick={() => dialog({
headline: "原始数据",
body: `<span style="word-break: break-word;">${Object.keys(message)
// @ts-ignore 懒
.map((k) => `${k} = ${message[k]}`)
.join('<br><br>')}<span>`,
closeOnEsc: true,
closeOnOverlayClick: true,
actions: [
{
text: "关闭",
onClick: () => {
return true
},
}
]
}).addEventListener('click', (e) => e.stopPropagation())}></mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
</mdui-card>
</div>
)
}

View File

@@ -1,17 +0,0 @@
interface Args extends React.HTMLAttributes<HTMLElement> {}
export default function MessageContainer({ children, style, ...props }: Args) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'center',
marginBottom: '20px',
...style,
}}
{...props}>
{children}
</div>
)
}

View File

@@ -1,24 +0,0 @@
export default function SystemMessage({ children }: React.HTMLAttributes<HTMLElement>) {
return (
<div style={{
width: '100%',
flexDirection: 'column',
display: 'flex',
marginTop: '25px',
marginBottom: '20px',
}}>
<mdui-card variant="filled"
style={{
alignSelf: 'center',
paddingTop: '8px',
paddingBottom: '8px',
paddingLeft: '17px',
paddingRight: '17px',
fontSize: '92%',
}}>
{children}
</mdui-card>
</div>
)
}

View File

@@ -1,20 +0,0 @@
export default function copyToClipboard(text: string) {
if (!("via" in window) && navigator.clipboard)
return navigator.clipboard.writeText(text)
return new Promise((res, rej) => {
if (document.hasFocus()) {
const a = document.createElement("textarea")
document.body.appendChild(a)
a.style.position = "fixed"
a.style.clip = "rect(0 0 0 0)"
a.style.top = "10px"
a.value = text
a.select()
document.execCommand("cut", true)
document.body.removeChild(a)
res(null)
} else {
rej()
}
})
}

View File

@@ -1,27 +0,0 @@
import { $ } from 'mdui/jq'
customElements.define('chat-file', class extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
const e = new DOMParser().parseFromString(`
<a style="width: 100%;height: 100%;">
<mdui-card 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 HTMLElement
$(e).find('span').text($(this).attr("name"))
const href = $(this).attr('href')
$(e).attr('href', href)
$(e).attr('target', '_blank')
$(e).attr('download', href)
e.style.textDecoration = 'none'
e.style.color = 'inherit'
e.onclick = (e) => {
e.stopPropagation()
}
this.appendChild(e)
}
})

View File

@@ -1,47 +0,0 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts"
import EventBus from "../../EventBus.ts"
interface Refs {
addContactDialogRef: React.MutableRefObject<Dialog | null>
}
export default function AddContactDialog({
addContactDialogRef,
}: Refs) {
const inputTargetRef = React.useRef<TextField>(null)
async function addContact() {
const re = await Client.invoke("User.addContacts", {
targets: [inputTargetRef.current!.value],
token: data.access_token,
})
if (checkApiSuccessOrSncakbar(re, "添加失敗")) return
snackbar({
message: re.msg,
placement: "top",
})
EventBus.emit('ContactsList.updateContacts')
inputTargetRef.current!.value = ''
addContactDialogRef.current!.open = false
}
return (
<mdui-dialog close-on-overlay-click close-on-esc headline="添加对话" ref={addContactDialogRef}>
<mdui-text-field clearable label="对话 ID / 用户 ID / 用户名" ref={inputTargetRef as any} onKeyDown={(event) => {
if (event.key == 'Enter')
addContact()
}}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => addContactDialogRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={() => addContact()}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -1,54 +0,0 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts"
import EventBus from "../../EventBus.ts"
interface Refs {
createGroupDialogRef: React.MutableRefObject<Dialog | null>
}
export default function CreateGroupDialog({
createGroupDialogRef,
}: Refs) {
const inputGroupTitleRef = React.useRef<TextField>(null)
const inputGroupNameRef = React.useRef<TextField>(null)
async function createGroup() {
const re = await Client.invoke("Chat.createGroup", {
title: inputGroupTitleRef.current!.value,
name: inputGroupNameRef.current!.value,
token: data.access_token,
})
if (checkApiSuccessOrSncakbar(re, "添加失敗")) return
snackbar({
message: "创建成功!",
placement: "top",
})
EventBus.emit('ContactsList.updateContacts')
inputGroupTitleRef.current!.value = ''
inputGroupNameRef.current!.value = ''
createGroupDialogRef.current!.open = false
}
return (
<mdui-dialog close-on-overlay-click close-on-esc headline="创建群组" ref={createGroupDialogRef}>
<mdui-text-field clearable label="群组名称" ref={inputGroupTitleRef as any} onKeyDown={(event) => {
if (event.key == 'Enter')
inputGroupNameRef.current!.click()
}}></mdui-text-field>
<mdui-text-field style={{ marginTop: "10px", }} clearable label="群组别名 (可选, 供查询)" ref={inputGroupNameRef as any} onKeyDown={(event) => {
if (event.key == 'Enter')
createGroup()
}}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => createGroupDialogRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={() => createGroup()}></mdui-button>
</mdui-dialog>
)
}

View File

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

View File

@@ -1,198 +0,0 @@
import * as React from 'react'
import { Button, Dialog, TextField, dialog } from "mdui"
import useEventListener from "../useEventListener.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Client from "../../api/Client.ts"
import * as CryptoJS from 'crypto-js'
import data from "../../Data.ts"
import Avatar from "../Avatar.tsx"
import User from "../../api/client_data/User.ts"
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
interface Refs {
myProfileDialogRef: React.MutableRefObject<Dialog>
user: User
}
export default function MyProfileDialog({
myProfileDialogRef,
user
}: Refs) {
const editAvatarButtonRef = React.useRef<HTMLElement>(null)
const chooseAvatarFileRef = React.useRef<HTMLInputElement>(null)
useEventListener(editAvatarButtonRef, 'click', () => {
chooseAvatarFileRef.current!.value = ''
chooseAvatarFileRef.current!.click()
})
useEventListener(chooseAvatarFileRef, 'change', async (_e) => {
const file = chooseAvatarFileRef.current!.files?.[0] as File
if (file == null) return
let re = await Client.uploadFileLikeApi(
'avatar',
file
)
if (checkApiSuccessOrSncakbar(re, "上传失败")) return
const hash = re.data!.file_hash
re = await Client.invoke("User.setAvatar", {
token: data.access_token,
file_hash: hash
})
if (checkApiSuccessOrSncakbar(re, "修改失败")) return
snackbar({
message: "修改成功 (刷新页面以更新)",
placement: "top",
})
})
const userProfileEditDialogRef = React.useRef<Dialog>(null)
const editNickNameRef = React.useRef<TextField>(null)
const editUserNameRef = React.useRef<TextField>(null)
const accountSettingsDialogRef = React.useRef<Dialog>(null)
const editPasswordDialogRef = React.useRef<Dialog>(null)
const editPasswordOldInputRef = React.useRef<TextField>(null)
const editPasswordNewInputRef = React.useRef<TextField>(null)
return (<>
{
// 公用 - 資料卡
}
<mdui-dialog close-on-overlay-click close-on-esc ref={myProfileDialogRef}>
<div style={{
display: 'flex',
alignItems: 'center',
}}>
<Avatar src={getUrlForFileByHash(user?.avatar_file_hash)} text={user?.nickname} style={{
width: '50px',
height: '50px',
}} />
<span style={{
marginLeft: "15px",
fontSize: '16.5px',
}}>{user?.nickname}</span>
</div>
<mdui-divider style={{
marginTop: "10px",
}}></mdui-divider>
<mdui-list>
<mdui-list-item icon="edit" rounded onClick={() => userProfileEditDialogRef.current!.open = true}></mdui-list-item>
<mdui-list-item icon="settings" rounded onClick={() => accountSettingsDialogRef.current!.open = true}></mdui-list-item>
{/*
<mdui-list-item icon="lock" rounded>隱私設定</mdui-list-item>
*/}
<mdui-list-item icon="logout" rounded onClick={() => dialog({
headline: "退出登录",
description: "请确保在退出登录前, 设定了用户名或者已经记录下了用户 ID, 以免无法登录账号",
actions: [
{
text: "取消",
onClick: () => {
return true
},
},
{
text: "确定",
onClick: () => {
data.refresh_token = ''
data.access_token = ''
data.apply()
location.reload()
return true
},
}
],
closeOnEsc: true,
closeOnOverlayClick: true,
})}>退</mdui-list-item>
</mdui-list>
</mdui-dialog>
{
// 账号设定
}
<mdui-dialog close-on-overlay-click close-on-esc ref={accountSettingsDialogRef} headline="账号设定">
<mdui-list-item icon="edit" rounded onClick={() => editPasswordDialogRef.current!.open = true}></mdui-list-item>
<mdui-button slot="action" variant="text" onClick={() => accountSettingsDialogRef.current!.open = false}></mdui-button>
</mdui-dialog>
<mdui-dialog close-on-overlay-click close-on-esc ref={editPasswordDialogRef} headline="修改密码">
<mdui-text-field label="旧密码" type="password" toggle-password ref={editPasswordOldInputRef as any}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="新密码" type="password" toggle-password ref={editPasswordNewInputRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => editPasswordDialogRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={async () => {
const re = await Client.invoke("User.resetPassword", {
token: data.access_token,
old_password: CryptoJS.SHA256(editPasswordOldInputRef.current?.value || '').toString(CryptoJS.enc.Hex),
new_password: CryptoJS.SHA256(editPasswordNewInputRef.current?.value || '').toString(CryptoJS.enc.Hex),
})
if (checkApiSuccessOrSncakbar(re, "修改失败")) return
snackbar({
message: "修改成功 (其他客户端需要重新登录)",
placement: "top",
})
data.access_token = re.data!.access_token as string
data.refresh_token = re.data!.refresh_token as string
data.apply()
editPasswordDialogRef.current!.open = false
}}></mdui-button>
</mdui-dialog>
{
// 個人資料編輯
}
<mdui-dialog close-on-overlay-click close-on-esc ref={userProfileEditDialogRef}>
<div style={{
display: "none"
}}>
<input type="file" name="选择头像" ref={chooseAvatarFileRef}
accept="image/*" />
</div>
<div style={{
display: 'flex',
alignItems: 'center',
}}>
<Avatar src={getUrlForFileByHash(user?.avatar_file_hash)} text={user?.nickname} avatarRef={editAvatarButtonRef} style={{
width: '50px',
height: '50px',
}} />
<mdui-text-field variant="outlined" placeholder="昵称" ref={editNickNameRef as any} style={{
marginLeft: "15px",
}} value={user?.nickname}></mdui-text-field>
</div>
<mdui-divider style={{
marginTop: "10px",
}}></mdui-divider>
<mdui-text-field style={{ marginTop: "10px", }} variant="outlined" label="用户 ID" value={user?.id || ''} readonly onClick={(e) => {
const input = e.target as HTMLInputElement
input.select()
input.setSelectionRange(0, 1145141919810)
}}></mdui-text-field>
<mdui-text-field style={{ marginTop: "20px", }} variant="outlined" label="用户名" value={user?.username || ''} ref={editUserNameRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => userProfileEditDialogRef.current!.open = false}></mdui-button>
<mdui-button slot="action" variant="text" onClick={async () => {
const re = await Client.invoke("User.updateProfile", {
token: data.access_token,
nickname: editNickNameRef.current?.value,
username: editUserNameRef.current?.value,
})
if (checkApiSuccessOrSncakbar(re, "修改失败")) return
snackbar({
message: "修改成功 (刷新页面以更新)",
placement: "top",
})
userProfileEditDialogRef.current!.open = false
}}></mdui-button>
</mdui-dialog>
</>)
}

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.useRef<Button>(null)
const doRegisterButtonRef = React.useRef<Button>(null)
useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false)
useEventListener(doRegisterButtonRef, 'click', async () => {
const username = registerInputUserNameRef.current!.value
const re = await Client.invoke("User.register", {
username: username,
nickname: registerInputNickNameRef.current!.value,
password: CryptoJS.SHA256(registerInputPasswordRef.current!.value).toString(CryptoJS.enc.Hex),
})
if (checkApiSuccessOrSncakbar(re, "注册失败")) return
loginInputAccountRef.current!.value = username == "" ? re.data!.userid as string : username
loginInputPasswordRef.current!.value = registerInputPasswordRef.current!.value
registerInputUserNameRef.current!.value = ""
registerInputNickNameRef.current!.value = ""
registerInputPasswordRef.current!.value = ""
registerDialogRef.current!.open = false
snackbar({
message: "注册成功!",
placement: "top",
})
})
return (
<mdui-dialog headline="注册" ref={registerDialogRef}>
<mdui-text-field label="用户名 (可选)" ref={registerInputUserNameRef as any}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="昵称" ref={registerInputNickNameRef as any}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef as any}></mdui-text-field>
<mdui-button slot="action" variant="text" ref={registerBackButtonRef}></mdui-button>
<mdui-button slot="action" variant="text" ref={doRegisterButtonRef}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -1,3 +0,0 @@
export default function isMobileUI() {
return new URL(location.href).searchParams.get('mobile') == 'true' || /Mobi|Android|iPhone/i.test(navigator.userAgent)
}

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

@@ -1,28 +1,21 @@
import { TextField } from "mdui"
import useEventListener from "../useEventListener.ts"
import RecentsListItem from "./RecentsListItem.tsx"
import React from "react"
import useAsyncEffect from "../useAsyncEffect.ts"
import Client from "../../api/Client.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import data from "../../Data.ts"
import EventBus from "../../EventBus.ts"
import isMobileUI from "../isMobileUI.ts"
import Chat from "../../api/client_data/Chat.ts"
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"
interface Args extends React.HTMLAttributes<HTMLElement> {
display: boolean
currentChatId: string
openChatInfoDialog: (chat: Chat) => void
}
export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
myProfileCache: context.myProfileCache,
functions_lazy: context.functions_lazy,
}))
export default function AllChatsList({
currentChatId,
display,
openChatInfoDialog,
...props
}: Args) {
const searchRef = React.useRef<HTMLElement>(null)
const [searchText, setSearchText] = React.useState('')
const [allChatsList, setAllChatsList] = React.useState<Chat[]>([])
@@ -33,20 +26,21 @@ export default function AllChatsList({
useAsyncEffect(async () => {
async function updateAllChats() {
const re = await Client.invoke("User.getMyAllChats", {
token: data.access_token,
})
if (re.code != 200) {
if (re.code != 401 && re.code != 400) checkApiSuccessOrSncakbar(re, "获取所有对话列表失败")
return
try {
setAllChatsList(await shared.myProfileCache!.getMyAllChatsOrThrow())
} catch (e) {
if (e instanceof CallbackError)
if (e.code != 401 && e.code != 400)
showSnackbar({
message: '获取所有对话失败: ' + e.message
})
}
setAllChatsList(re.data!.all_chats as Chat[])
}
updateAllChats()
EventBus.on('AllChatsList.updateAllChats', () => updateAllChats())
shared.functions_lazy.current.updateAllChats = updateAllChats
return () => {
EventBus.off('AllChatsList.updateAllChats')
}
})
@@ -55,12 +49,12 @@ export default function AllChatsList({
paddingRight: '10px',
paddingLeft: '10px',
paddingTop: '0',
display: display ? undefined : 'none',
height: '100%',
width: '100%',
...props?.style,
}} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
paddingTop: '5px',
paddingTop: '12px',
paddingBottom: '13px',
position: 'sticky',
top: '0',
@@ -70,12 +64,12 @@ export default function AllChatsList({
{
allChatsList.filter((chat) =>
searchText == '' ||
chat.title.includes(searchText) ||
chat.id.includes(searchText)
chat.getTitle().includes(searchText) ||
chat.getId().includes(searchText)
).map((v) =>
<AllChatsListItem
active={isMobileUI() ? false : currentChatId == v.id}
key={v.id}
active={isMobileUI() ? false : currentChatId == v.getId()}
key={v.getId()}
onClick={() => {
openChatInfoDialog(v)
}}

View File

@@ -1,8 +1,8 @@
import { $ } from "mdui/jq"
import Avatar from "../Avatar.tsx"
import React from 'react'
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
import Chat from "../../api/client_data/Chat.ts"
import { Chat } from "lingchair-client-protocol"
import getClient from "../../getClient.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
chat: Chat
@@ -10,7 +10,7 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
}
export default function AllChatsListItem({ chat, active, ...prop }: Args) {
const { title, avatar_file_hash } = chat
const title = chat.getTitle()
const ref = React.useRef<HTMLElement>(null)
@@ -23,7 +23,7 @@ export default function AllChatsListItem({ chat, active, ...prop }: Args) {
<span style={{
width: "100%",
}}>{title}</span>
<Avatar src={getUrlForFileByHash(avatar_file_hash as string)} text={title} slot="icon" />
<Avatar src={getClient().getUrlForFileByHash(chat.getAvatarFileHash() as string)} text={title} slot="icon" />
</mdui-list-item>
)
}

View File

@@ -0,0 +1,170 @@
import React from "react"
import FavouriteChatsListItem from "./FavouriteChatsListItem.tsx"
import { dialog, TextField } from "mdui"
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import useEventListener from "../../utils/useEventListener.ts"
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
import showSnackbar from "../../utils/showSnackbar.ts"
import getClient from "../../getClient.ts"
import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
import isMobileUI from "../../utils/isMobileUI.ts"
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
myProfileCache: context.myProfileCache,
setShowAddFavourtieChatDialog: context.setShowAddFavourtieChatDialog,
functions_lazy: context.functions_lazy,
currentSelectedChatId: context.currentSelectedChatId,
values_lazy: context.values_lazy,
}))
const searchRef = React.useRef<HTMLElement>(null)
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
const [searchText, setSearchText] = React.useState('')
const [favouriteChatsList, setFavouriteChatsList] = React.useState<Chat[]>([])
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
})
useAsyncEffect(async () => {
async function updateFavouriteChats() {
try {
const ls = await shared.myProfileCache!.getMyFavouriteChatsOrThrow()
setFavouriteChatsList(ls)
shared.favourite_chats
} catch (e) {
if (e instanceof CallbackError)
if (e.code != 401 && e.code != 400)
showSnackbar({
message: '获取收藏对话失败: ' + e.message
})
console.log(e)
}
}
updateFavouriteChats()
shared.functions_lazy.current.updateFavouriteChats = updateFavouriteChats
return () => {
}
}, [shared.myProfileCache])
return <mdui-list style={{
overflowY: 'auto',
paddingLeft: '10px',
paddingRight: '10px',
paddingTop: '0',
height: '100%',
width: '100%',
...props?.style,
}} {...props}>
<div style={{
position: 'sticky',
top: '0',
backgroundColor: 'rgb(var(--mdui-color-background))',
zIndex: '10',
}}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
paddingTop: '12px',
}}></mdui-text-field>
<mdui-list-item rounded style={{
marginTop: '13px',
width: '100%',
}} icon="person_add" onClick={() => shared.setShowAddFavourtieChatDialog(true)}></mdui-list-item>
<mdui-list-item rounded style={{
width: '100%',
}} icon="refresh" onClick={() => shared.functions_lazy.current.updateFavouriteChats()}></mdui-list-item>
<mdui-list-item rounded style={{
width: '100%',
}} icon={isMultiSelecting ? "done" : "edit"} onClick={() => {
if (isMultiSelecting)
setCheckedList({})
setIsMultiSelecting(!isMultiSelecting)
}}>{isMultiSelecting ? "关闭多选" : "多选模式"}</mdui-list-item>
{
isMultiSelecting && <>
<mdui-list-item rounded style={{
width: '100%',
}} icon="delete" onClick={() => dialog({
headline: "移除收藏对话",
description: "确定将所选对话从收藏中移除吗? 这不会导致对话被删除.",
closeOnEsc: true,
closeOnOverlayClick: true,
actions: [
{
text: "取消",
onClick: () => {
return true
},
},
{
text: "确定",
onClick: async () => {
const ls = Object.keys(checkedList).filter((chatId) => checkedList[chatId] == true)
try {
shared.myProfileCache!.removeFavouriteChatsOrThrow(ls)
setCheckedList({})
setIsMultiSelecting(false)
shared.functions_lazy.current.updateFavouriteChats()
showSnackbar({
message: "已删除所选",
action: "撤销操作",
onActionClick: async () => {
try {
shared.myProfileCache!.addFavouriteChatsOrThrow(ls)
} catch (e) {
if (e instanceof CallbackError)
showSnackbar({
message: '撤销删除收藏失败: ' + e.message
})
}
shared.functions_lazy.current.updateFavouriteChats()
}
})
} catch (e) {
if (e instanceof CallbackError)
showSnackbar({
message: '删除收藏对话失败: ' + e.message
})
}
},
}
],
})}></mdui-list-item>
</>
}
<div style={{
height: "10px",
}}></div>
</div>
{
favouriteChatsList.filter((chat) =>
searchText == '' ||
chat.getTitle().includes(searchText) ||
chat.getId().includes(searchText)
).map((v) =>
<FavouriteChatsListItem
active={isMultiSelecting ? checkedList[v.getId()] == true : (isMobileUI() ? false : shared.currentSelectedChatId == v.getId())}
onClick={() => {
if (isMultiSelecting)
setCheckedList({
...checkedList,
[v.getId()]: !checkedList[v.getId()],
})
else
openChatInfoDialog(v)
}}
key={v.getId()}
chat={v} />
)
}
</mdui-list>
}

View File

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

View File

@@ -0,0 +1,49 @@
import * as React from 'react'
import { Dialog, TextField } from "mdui"
import performAuth from '../../performAuth.ts'
import showSnackbar from '../../utils/showSnackbar.ts'
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
import { useContextSelector } from 'use-context-selector'
import useEventListener from '../../utils/useEventListener.ts'
export default function LoginDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
setShowRegisterDialog: context.setShowRegisterDialog,
setShowLoginDialog: context.setShowLoginDialog
}))
const dialogRef = React.useRef<Dialog>()
useEventListener(dialogRef, 'closed', () => shared.setShowLoginDialog(false))
const loginInputAccountRef = React.useRef<TextField>(null)
const loginInputPasswordRef = React.useRef<TextField>(null)
return (
<mdui-dialog {...props} headline="登录" ref={dialogRef}>
<mdui-text-field label="用户 ID / 用户名" ref={loginInputAccountRef}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="密码" type="password" toggle-password ref={loginInputPasswordRef}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => shared.setShowRegisterDialog(true)}></mdui-button>
<mdui-button slot="action" variant="text" onClick={async () => {
const account = loginInputAccountRef.current!.value
const password = loginInputPasswordRef.current!.value
try {
await performAuth({
account: account,
password: password,
})
location.reload()
} catch (e) {
if (e instanceof Error)
showSnackbar({ message: '登录失败: ' + e.message })
}
}}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -0,0 +1,83 @@
import { TextField } from "mdui"
import RecentsListItem from "./RecentsListItem.tsx"
import React from "react"
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
import { data } from "react-router"
import isMobileUI from "../../utils/isMobileUI.ts"
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import useEventListener from "../../utils/useEventListener.ts"
import { CallbackError } from "lingchair-client-protocol"
import { useContextSelector } from "use-context-selector"
import showSnackbar from "../../utils/showSnackbar.ts"
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
myProfileCache: context.myProfileCache,
functions_lazy: context.functions_lazy,
currentSelectedChatId: context.currentSelectedChatId,
}))
const searchRef = React.useRef<HTMLElement>(null)
const [searchText, setSearchText] = React.useState('')
const [recentsList, setRecentsList] = React.useState<RecentChat[]>([])
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
})
useAsyncEffect(async () => {
async function updateRecents() {
try {
setRecentsList(await shared.myProfileCache!.getMyRecentChats())
} catch (e) {
if (e instanceof CallbackError)
if (e.code != 401 && e.code != 400)
showSnackbar({
message: '获取最近对话失败: ' + e.message
})
}
}
updateRecents()
shared.functions_lazy.current.updateRecentChats = updateRecents
const id = setInterval(() => updateRecents(), 15 * 1000)
return () => {
clearInterval(id)
}
})
return <mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
paddingLeft: '10px',
paddingTop: '0',
height: '100%',
width: '100%',
...props?.style,
}} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
paddingTop: '12px',
marginBottom: '13px',
position: 'sticky',
top: '0',
backgroundColor: 'rgb(var(--mdui-color-background))',
zIndex: '10',
}}></mdui-text-field>
{
recentsList.filter((chat) =>
searchText == '' ||
chat.getTitle().includes(searchText) ||
chat.getId().includes(searchText) ||
chat.getContent().includes(searchText)
).map((v) =>
<RecentsListItem
active={isMobileUI() ? false : shared.currentSelectedChatId == v.getId()}
openChatFragment={() => openChatFragment(v.getId())}
key={v.getId()}
recentChat={v} />
)
}
</mdui-list>
}

View File

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

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import MainSharedContext, { Shared } from '../MainSharedContext'
import showSnackbar from '../../utils/showSnackbar'
import showCircleProgressDialog from '../showCircleProgressDialog'
import getClient from '../../getClient'
import performAuth from '../../performAuth'
import { useContextSelector } from 'use-context-selector'
import useEventListener from '../../utils/useEventListener'
export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
setShowRegisterDialog: context.setShowRegisterDialog
}))
const dialogRef = React.useRef<Dialog>()
useEventListener(dialogRef, 'closed', () => shared.setShowRegisterDialog(false))
const registerInputUserNameRef = React.useRef<TextField>(null)
const registerInputNickNameRef = React.useRef<TextField>(null)
const registerInputPasswordRef = React.useRef<TextField>(null)
return (
<mdui-dialog headline="注册" {...props} ref={dialogRef}>
<mdui-text-field label="用户名 (可选)" ref={registerInputUserNameRef}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="昵称" ref={registerInputNickNameRef}></mdui-text-field>
<div style={{
height: "10px",
}}></div>
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef}></mdui-text-field>
<mdui-button slot="action" variant="text" onClick={() => shared.setShowRegisterDialog(false)}></mdui-button>
<mdui-button slot="action" variant="text" onClick={async () => {
const waitingForRegister = showCircleProgressDialog("注册中...")
const username = registerInputUserNameRef.current!.value
let user_id: string
try {
user_id = await getClient().registerOrThrow({
username: username,
nickname: registerInputNickNameRef.current!.value,
password: registerInputPasswordRef.current!.value,
})
} catch (e) {
user_id = ''
if (e instanceof Error) {
waitingForRegister.open = false
showSnackbar({ message: '注册失败: ' + e.message })
return
}
}
waitingForRegister.open = false
const waitingForLogin = showCircleProgressDialog("登录中...")
try {
await performAuth({
account: username == '' ? username : user_id,
password: registerInputPasswordRef.current!.value,
})
location.reload()
} catch (e) {
if (e instanceof Error) {
showSnackbar({ message: '登录失败: ' + e.message })
}
}
waitingForLogin.open = false
}}></mdui-button>
</mdui-dialog>
)
}

View File

@@ -1,171 +0,0 @@
import React from "react"
import ContactsListItem from "./ContactsListItem.tsx"
import useEventListener from "../useEventListener.ts"
import { dialog, Dialog, TextField } from "mdui"
import Client from "../../api/Client.ts"
import data from "../../Data.ts"
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts"
import Chat from "../../api/client_data/Chat.ts"
import EventBus from "../../EventBus.ts"
import isMobileUI from "../isMobileUI.ts";
interface Args extends React.HTMLAttributes<HTMLElement> {
display: boolean
openChatInfoDialog: (chat: Chat) => void
addContactDialogRef: React.MutableRefObject<Dialog>
createGroupDialogRef: React.MutableRefObject<Dialog>
setSharedFavouriteChats: React.Dispatch<React.SetStateAction<Chat[]>>
currentChatId: string
}
export default function ContactsList({
display,
openChatInfoDialog,
addContactDialogRef,
createGroupDialogRef,
setSharedFavouriteChats,
currentChatId,
...props
}: Args) {
const searchRef = React.useRef<HTMLElement>(null)
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
const [searchText, setSearchText] = React.useState('')
const [contactsList, setContactsList] = React.useState<Chat[]>([])
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
})
React.useEffect(() => {
async function updateContacts() {
const re = await Client.invoke("User.getMyContacts", {
token: data.access_token,
})
if (re.code != 200) {
if (re.code != 401 && re.code != 400) checkApiSuccessOrSncakbar(re, "获取收藏对话列表失败")
return
}
const ls = re.data!.contacts_list as Chat[]
setContactsList(ls)
setSharedFavouriteChats(ls)
}
updateContacts()
EventBus.on('ContactsList.updateContacts', () => updateContacts())
return () => {
EventBus.off('ContactsList.updateContacts')
}
// 警告: 不添加 deps 導致無限執行
}, [])
return <mdui-list style={{
overflowY: 'auto',
paddingLeft: '10px',
paddingRight: '10px',
paddingTop: '0',
display: display ? undefined : 'none',
height: '100%',
width: '100%',
}} {...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={{
marginTop: '5px',
}}></mdui-text-field>
<mdui-list-item rounded style={{
marginTop: '13px',
width: '100%',
}} icon="person_add" onClick={() => addContactDialogRef.current!.open = true}></mdui-list-item>
<mdui-list-item rounded style={{
width: '100%',
}} icon="refresh" onClick={() => EventBus.emit('ContactsList.updateContacts')}></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)
const re = await Client.invoke("User.removeContacts", {
token: data.access_token,
targets: ls,
})
if (re.code != 200)
checkApiSuccessOrSncakbar(re, "删除所选收藏失败")
else {
setCheckedList({})
setIsMultiSelecting(false)
EventBus.emit('ContactsList.updateContacts')
snackbar({
message: "已删除所选",
placement: "top",
action: "撤销操作",
onActionClick: async () => {
const re = await Client.invoke("User.addContacts", {
token: data.access_token,
targets: ls,
})
if (re.code != 200)
checkApiSuccessOrSncakbar(re, "恢复所选收藏失败")
EventBus.emit('ContactsList.updateContacts')
}
})
}
},
}
],
})}></mdui-list-item>
</>
}
<div style={{
height: "10px",
}}></div>
</div>
{
contactsList.filter((chat) =>
searchText == '' ||
chat.title.includes(searchText) ||
chat.id.includes(searchText)
).map((v) =>
<ContactsListItem
active={isMultiSelecting ? checkedList[v.id] == true : (isMobileUI() ? false : currentChatId == v.id)}
onClick={() => {
if (isMultiSelecting)
setCheckedList({
...checkedList,
[v.id]: !checkedList[v.id],
})
else
openChatInfoDialog(v)
}}
key={v.id}
contact={v} />
)
}
</mdui-list>
}

View File

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

View File

@@ -1,86 +0,0 @@
import { TextField } from "mdui"
import RecentChat from "../../api/client_data/RecentChat.ts"
import useEventListener from "../useEventListener.ts"
import RecentsListItem from "./RecentsListItem.tsx"
import React from "react"
import useAsyncEffect from "../useAsyncEffect.ts"
import Client from "../../api/Client.ts"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts";
import data from "../../Data.ts";
import EventBus from "../../EventBus.ts";
import isMobileUI from "../isMobileUI.ts";
interface Args extends React.HTMLAttributes<HTMLElement> {
display: boolean
currentChatId: string
openChatFragment: (id: string) => void
}
export default function RecentsList({
currentChatId,
display,
openChatFragment,
...props
}: Args) {
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() {
const re = await Client.invoke("User.getMyRecentChats", {
token: data.access_token,
})
if (re.code != 200) {
if (re.code != 401 && re.code != 400) checkApiSuccessOrSncakbar(re, "获取最近对话列表失败")
return
}
setRecentsList(re.data!.recent_chats as RecentChat[])
}
updateRecents()
EventBus.on('RecentsList.updateRecents', () => updateRecents())
const id = setInterval(() => updateRecents(), 15 * 1000)
return () => {
EventBus.off('RecentsList.updateRecents')
clearInterval(id)
}
})
return <mdui-list style={{
overflowY: 'auto',
paddingRight: '10px',
paddingLeft: '10px',
paddingTop: '0',
display: display ? undefined : 'none',
height: '100%',
width: '100%',
}} {...props}>
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
marginTop: '5px',
marginBottom: '13px',
position: 'sticky',
top: '0',
backgroundColor: 'rgb(var(--mdui-color-background))',
zIndex: '10',
}}></mdui-text-field>
{
recentsList.filter((chat) =>
searchText == '' ||
chat.title.includes(searchText) ||
chat.id.includes(searchText) ||
chat.content.includes(searchText)
).map((v) =>
<RecentsListItem
active={isMobileUI() ? false : currentChatId == v.id}
openChatFragment={() => openChatFragment(v.id)}
key={v.id}
recentChat={v} />
)
}
</mdui-list>
}

View File

@@ -1,16 +0,0 @@
import { ListItem } from "mdui"
interface Args extends React.HTMLAttributes<ListItem> {
title: string
description?: string
icon: string
disabled?: boolean
}
export default function Preference({ title, icon, disabled, description, ...props }: Args) {
// @ts-ignore: 为什么 ...props 要说参数不兼容呢?
return <mdui-list-item disabled={disabled ? true : undefined} rounded icon={icon} {...props}>
{title}
{description && <span slot="description">{description}</span>}
</mdui-list-item>
}

View File

@@ -1,5 +0,0 @@
export default function PreferenceHeader({ title }: {
title: string
}) {
return <mdui-list-subheader>{title}</mdui-list-subheader>
}

View File

@@ -1,8 +0,0 @@
export default function PreferenceLayout({ children, ...props }: React.HTMLAttributes<HTMLElement>) {
return <mdui-list style={{
marginLeft: '15px',
marginRight: '15px',
}} {...props}>
{children}
</mdui-list>
}

View File

@@ -1,27 +0,0 @@
import React from 'react'
export default class PreferenceStore<T extends object> {
declare onUpdate: (value: T, oldvalue: T) => void
declare state: T
declare setState: React.Dispatch<React.SetStateAction<T>>
constructor() {
const _ = React.useState({} as T)
this.state = _[0]
this.setState = _[1]
}
createUpdater() {
return (key: string, value: unknown) => {
const oldvalue = this.state
const newValue = {
...this.state,
[key]: value,
}
this.setState(newValue)
this.onUpdate?.(newValue, oldvalue)
}
}
setOnUpdate(onUpdate: (value: T, oldvalue: T) => void) {
this.onUpdate = onUpdate
}
}

View File

@@ -1,6 +0,0 @@
import React from 'react'
// deno-lint-ignore no-explicit-any
const PreferenceUpdater = React.createContext<(key: string, value: unknown) => void>(null as any)
export default PreferenceUpdater

View File

@@ -1,43 +0,0 @@
import React from 'react'
import { Dropdown } from 'mdui'
import useEventListener from '../useEventListener.ts'
import PreferenceUpdater from "./PreferenceUpdater.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
title: string
icon: string
id: string
disabled?: boolean
selections: { [id: string]: string }
state: string
}
export default function SelectPreference({ title, icon, id: preferenceId, selections, state, disabled }: Args) {
const updater = React.useContext(PreferenceUpdater)
const dropDownRef = React.useRef<Dropdown>(null)
const [isDropDownOpen, setDropDownOpen] = React.useState(false)
useEventListener(dropDownRef, 'closed', () => {
setDropDownOpen(false)
})
return <mdui-list-item icon={icon} rounded disabled={disabled ? true : undefined} onClick={() => setDropDownOpen(!isDropDownOpen)}>
<mdui-dropdown ref={dropDownRef} trigger="manual" open={isDropDownOpen}>
<span slot="trigger">{title}</span>
<mdui-menu onClick={(e) => {
e.stopPropagation()
setDropDownOpen(false)
}}>
{
Object.keys(selections).map((id) =>
// @ts-ignore: selected 确实存在, 但是并不对外公开使用
<mdui-menu-item key={id} selected={state == id ? true : undefined} onClick={() => {
updater(preferenceId, id)
}}>{selections[id]}</mdui-menu-item>
)
}
</mdui-menu>
</mdui-dropdown>
<span slot="description">{selections[state]}</span>
</mdui-list-item>
}

View File

@@ -1,30 +0,0 @@
import { Switch } from 'mdui'
import React from 'react'
import PreferenceUpdater from "./PreferenceUpdater.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
title: string
id: string
description?: string
icon: string
state: boolean
disabled?: boolean
}
export default function SwitchPreference({ title, icon, id, disabled, description, state }: Args) {
const updater = React.useContext(PreferenceUpdater)
const switchRef = React.useRef<Switch>(null)
React.useEffect(() => {
switchRef.current!.checked = state
}, [state])
return <mdui-list-item disabled={disabled ? true : undefined} rounded icon={icon} onClick={() => {
updater(id, !state)
}}>
{title}
{description && <span slot="description">{description}</span>}
<mdui-switch slot="end-icon" checked-icon="" ref={switchRef} onClick={(e) => e.preventDefault()}></mdui-switch>
</mdui-list-item>
}

View File

@@ -1,37 +0,0 @@
import React from 'react'
import { prompt } from 'mdui'
import PreferenceUpdater from "./PreferenceUpdater.ts"
interface Args extends React.HTMLAttributes<HTMLElement> {
title: string
description?: string
icon: string
id: string
state: string
disabled?: boolean
}
export default function TextFieldPreference({ title, icon, description, id, state, disabled }: Args) {
const updater = React.useContext(PreferenceUpdater)
return <mdui-list-item icon={icon} rounded disabled={disabled ? true : undefined} onClick={() => {
prompt({
headline: title,
confirmText: "确定",
cancelText: "取消",
onConfirm: (value) => {
updater(id, value)
},
onCancel: () => { },
textFieldOptions: {
label: description,
value: state,
},
closeOnEsc: true,
closeOnOverlayClick: true,
})
}}>
{title}
{description && <span slot="description">{description}</span>}
</mdui-list-item>
}

View File

@@ -1,52 +1,84 @@
import React from 'react'
import Chat from "../../api/client_data/Chat.ts"
import useAsyncEffect from "../useAsyncEffect.ts"
import Client from "../../api/Client.ts"
import data from "../../Data.ts"
import { dialog, Dialog } from "mdui"
import Avatar from "../Avatar.tsx"
import { checkApiSuccessOrSncakbar } from "../snackbar.ts"
import User from "../../api/client_data/User.ts"
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
import openImageViewer from "../openImageViewer.ts"
import EventBus from "../../EventBus.ts"
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'
interface Args extends React.HTMLAttributes<HTMLElement> {
chat?: Chat
openChatFragment: (id: string) => void
chatInfoDialogRef: React.MutableRefObject<Dialog>
sharedFavouriteChats: Chat[]
}
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')
export default function ChatInfoDialog({ chat, chatInfoDialogRef, openChatFragment, sharedFavouriteChats }: Args) {
const [favourited, setIsFavourited] = React.useState(false)
React.useEffect(() => {
setIsFavourited(shared.favouriteChats.map((v) => v.getId()).indexOf(chat?.getId() || '') != -1)
}, [chat, shared])
React.useEffect(() => {
setIsFavourited(sharedFavouriteChats.map((v) => v.id).indexOf(chat?.id || '') != -1)
})
console.log(currentLocation)
}, [currentLocation])
const [userId, setUserId] = React.useState<string | null>(null)
useAsyncEffect(async () => {
if (chat?.type == 'private') {
const re = await Client.invoke("Chat.getAnotherUserIdFromPrivate", {
token: data.access_token,
target: chat.id,
})
if (re.code != 200)
return checkApiSuccessOrSncakbar(re, '获取用户失败')
setUserId(re.data!.user_id as string)
}
}, [chat, sharedFavouriteChats])
console.log(id, currentLocation.pathname)
try {
if (!currentLocation.pathname.startsWith('/info/')) {
dialogRef.current!.open = false
return
}
const avatarUrl = getUrlForFileByHash(chat?.avatar_file_hash as string)
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 close-on-overlay-click close-on-esc ref={chatInfoDialogRef}>
<mdui-dialog ref={dialogRef}>
<div style={{
display: 'flex',
alignItems: 'center',
}}>
<Avatar src={avatarUrl} text={chat?.nickname as string} style={{
<Avatar src={avatarUrl} text={chat?.getTitle()} style={{
width: '50px',
height: '50px',
}} onClick={() => avatarUrl && openImageViewer(avatarUrl)} />
@@ -59,12 +91,12 @@ export default function ChatInfoDialog({ chat, chatInfoDialogRef, openChatFragme
}}>
<span style={{
fontSize: '16.5px'
}}>{chat?.title}</span>
}}>{chat?.getTitle()}</span>
<span style={{
fontSize: '10.5px',
marginTop: '3px',
color: 'rgb(var(--mdui-color-secondary))',
}}>({chat?.type}) ID: {chat?.type == 'private' ? userId : chat?.id}</span>
}}>({chat?.getType()}) ID: {chat?.getType() == 'private' ? userId : chat?.getId()}</span>
</div>
</div>
<mdui-divider style={{
@@ -109,3 +141,4 @@ export default function ChatInfoDialog({ chat, chatInfoDialogRef, openChatFragme
</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
}

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