Compare commits
54 Commits
d486c9df79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20986af1ba | ||
|
|
34d46a85f1 | ||
|
|
f8f66f0e33 | ||
|
|
58f0427350 | ||
|
|
e3db26323b | ||
|
|
4788434445 | ||
|
|
07bc4a6654 | ||
|
|
bd49edb586 | ||
|
|
f4a9cc9cda | ||
|
|
8817663371 | ||
|
|
19b8b92f49 | ||
|
|
f584b49cd4 | ||
|
|
13eefdd50c | ||
|
|
3cd9031eef | ||
|
|
94c901a233 | ||
|
|
1819c31267 | ||
|
|
00371b1dda | ||
|
|
2d48d2f536 | ||
|
|
4214ed9e10 | ||
|
|
198493cac1 | ||
|
|
f57347b834 | ||
|
|
f9dff68339 | ||
|
|
48bd884690 | ||
|
|
b85b6833b6 | ||
|
|
29ea0c5b84 | ||
|
|
508218a1c5 | ||
|
|
98774036cd | ||
|
|
e15e1aa4c8 | ||
|
|
1c6c0eaf84 | ||
|
|
02b0708426 | ||
|
|
d433ceb4a9 | ||
|
|
d76abcf512 | ||
|
|
6ca9946499 | ||
|
|
a549773eb2 | ||
|
|
faf594b2f6 | ||
|
|
185f5480fa | ||
|
|
b4a60bcbe2 | ||
|
|
d57b023769 | ||
|
|
4b5f0bcdd6 | ||
|
|
3f9ce06ed6 | ||
|
|
3def4d7449 | ||
|
|
4b9d78d0d5 | ||
|
|
1f6f8a768f | ||
|
|
a7c61d9306 | ||
|
|
0247eaeda9 | ||
|
|
f9dfa466f0 | ||
|
|
c2f99f5c62 | ||
|
|
6f6dd3bfac | ||
|
|
e7f0af8e6e | ||
|
|
3bd0d79fdc | ||
|
|
1e213ddbc4 | ||
|
|
839fb4c4b7 | ||
|
|
35afcf03bb | ||
|
|
5864108f99 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,9 +2,6 @@
|
|||||||
thewhitesilk_config.json
|
thewhitesilk_config.json
|
||||||
# **默认**数据目录
|
# **默认**数据目录
|
||||||
thewhitesilk_data/
|
thewhitesilk_data/
|
||||||
|
# Node.js
|
||||||
deno.lock
|
package-lock.json
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
#npm
|
|
||||||
package-lock.json
|
|
||||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -5,10 +5,10 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"command": "deno task build-and-run-server",
|
"command": "npm run debug",
|
||||||
"name": "Run debug",
|
"name": "Debug",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal"
|
||||||
},
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"deno.enable": true,
|
|
||||||
"deno.disablePaths": [
|
|
||||||
"./thewhitesilk_data",
|
|
||||||
"./client/mdui_patched"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,12 @@ WORKDIR /app
|
|||||||
COPY --exclude=.git --exclude=.gitignore --exclude=Dockerfile --exclude=readme.md --exclude=thewhitesilk_config.json --exclude=thewhitesilk_data . .
|
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
|
EXPOSE 3601
|
||||||
|
|
||||||
# 启动服务
|
# 启动服务
|
||||||
CMD ["deno", "task", "build-and-run-server"]
|
CMD ["npm", "run", "server"]
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
||||||
|
|
||||||
export default class CallbackError extends Error {
|
export default class CallbackError extends Error {
|
||||||
|
declare code: number
|
||||||
|
declare data?: object
|
||||||
constructor(re: ApiCallbackMessage) {
|
constructor(re: ApiCallbackMessage) {
|
||||||
super(`[${re.code}] ${re.msg}${re.data ? ` (data: ${JSON.stringify(re.data)})` : ''}`)
|
super(`[${re.code}] ${re.msg}${re.data ? ` (data: ${JSON.stringify(re.data)})` : ''}`)
|
||||||
|
this.code = re.code
|
||||||
|
this.data = re.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
static async getById(client: LingChairClient, id: string) {
|
||||||
try {
|
try {
|
||||||
return await this.getByIdOrThrow(client, id)
|
return await this.getByIdOrThrow(client, id)
|
||||||
|
|||||||
@@ -3,18 +3,10 @@ import { io, ManagerOptions, Socket, SocketOptions } from 'socket.io-client'
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import { CallMethod, ClientEvent, ClientEventCallback } from './ApiDeclare.ts'
|
import { CallMethod, ClientEvent, ClientEventCallback } from './ApiDeclare.ts'
|
||||||
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
||||||
import User from "./User.ts"
|
import { CallableMethodBeforeAuth, randomUUID } from "lingchair-internal-shared"
|
||||||
import UserMySelf from "./UserMySelf.ts"
|
|
||||||
import CallbackError from "./CallbackError.ts"
|
import CallbackError from "./CallbackError.ts"
|
||||||
import Chat from "./Chat.ts"
|
|
||||||
import { CallableMethodBeforeAuth } from "lingchair-internal-shared"
|
|
||||||
import Message from "./Message.ts";
|
|
||||||
|
|
||||||
export {
|
import Message from "./Message.ts"
|
||||||
User,
|
|
||||||
Chat,
|
|
||||||
UserMySelf,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class LingChairClient {
|
export default class LingChairClient {
|
||||||
declare client: Socket
|
declare client: Socket
|
||||||
@@ -44,7 +36,7 @@ export default class LingChairClient {
|
|||||||
auth: {
|
auth: {
|
||||||
...args.io?.auth,
|
...args.io?.auth,
|
||||||
device_id: this.device_id,
|
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) => {
|
this.client.on("The_White_Silk", (name: ClientEvent, data: any, _callback: (ret: unknown) => void) => {
|
||||||
@@ -53,7 +45,7 @@ export default class LingChairClient {
|
|||||||
for (const v of (this.events[name] || []))
|
for (const v of (this.events[name] || []))
|
||||||
v(({
|
v(({
|
||||||
"Client.onMessage": {
|
"Client.onMessage": {
|
||||||
message: new Message(this, data)
|
message: new Message(this, data.msg)
|
||||||
}
|
}
|
||||||
})[name])
|
})[name])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -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: {
|
async auth(args: {
|
||||||
refresh_token?: string,
|
refresh_token?: string,
|
||||||
access_token?: string,
|
access_token?: string,
|
||||||
@@ -119,27 +130,38 @@ export default class LingChairClient {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 进行身份验证以接受客户端事件
|
||||||
|
*
|
||||||
|
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
|
||||||
|
*
|
||||||
|
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
|
||||||
|
*
|
||||||
|
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
|
||||||
|
*
|
||||||
|
* 多个验证方式不会逐一尝试
|
||||||
|
*/
|
||||||
async authOrThrow(args: {
|
async authOrThrow(args: {
|
||||||
refresh_token?: string,
|
refresh_token?: string
|
||||||
access_token?: string,
|
access_token?: string
|
||||||
account?: string,
|
account?: string
|
||||||
password?: string,
|
password?: string
|
||||||
|
ignore_all_empty?: boolean
|
||||||
}) {
|
}) {
|
||||||
if ((!args.access_token && !args.refresh_token) && (!args.account && !args.password))
|
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')
|
throw new Error('Access/Refresh token or account & password required, or ignore_all_empty=true')
|
||||||
|
|
||||||
this.auth_cache = args
|
this.auth_cache = args
|
||||||
|
|
||||||
this.refresh_token = args.refresh_token
|
|
||||||
|
|
||||||
let access_token = args.access_token
|
let access_token = args.access_token
|
||||||
if (!access_token && args.refresh_token) {
|
if (!access_token && args.refresh_token) {
|
||||||
const re = await this.invoke('User.refreshAccessToken', {
|
const re = await this.invoke('User.refreshAccessToken', {
|
||||||
refresh_token: args.refresh_token,
|
refresh_token: args.refresh_token,
|
||||||
})
|
})
|
||||||
if (re.code == 200)
|
if (re.code == 200) {
|
||||||
access_token = re.data!.access_token as string | undefined
|
access_token = re.data!.access_token as string | undefined
|
||||||
else
|
this.refresh_token = args.refresh_token
|
||||||
|
} else
|
||||||
throw new CallbackError(re)
|
throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +170,10 @@ export default class LingChairClient {
|
|||||||
account: args.account,
|
account: args.account,
|
||||||
password: crypto.createHash('sha256').update(args.password).digest('hex'),
|
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
|
access_token = re.data!.access_token as string | undefined
|
||||||
else
|
this.refresh_token = re.data!.refresh_token as string
|
||||||
|
} else
|
||||||
throw new CallbackError(re)
|
throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,18 +185,6 @@ export default class LingChairClient {
|
|||||||
else
|
else
|
||||||
throw new CallbackError(re)
|
throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
async register(args: {
|
|
||||||
nickname: string,
|
|
||||||
username?: string,
|
|
||||||
password: string,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
await this.registerOrThrow(args)
|
|
||||||
return true
|
|
||||||
} catch (_) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getBaseHttpUrl() {
|
getBaseHttpUrl() {
|
||||||
const url = new URL(this.server_url)
|
const url = new URL(this.server_url)
|
||||||
return (({
|
return (({
|
||||||
@@ -186,6 +197,17 @@ export default class LingChairClient {
|
|||||||
getUrlForFileByHash(file_hash?: string, defaultUrl?: string) {
|
getUrlForFileByHash(file_hash?: string, defaultUrl?: string) {
|
||||||
return file_hash ? (this.getBaseHttpUrl() + '/uploaded_files/' + file_hash) : defaultUrl
|
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({
|
async registerOrThrow({
|
||||||
nickname,
|
nickname,
|
||||||
username,
|
username,
|
||||||
@@ -198,10 +220,11 @@ export default class LingChairClient {
|
|||||||
const re = await this.invoke('User.register', {
|
const re = await this.invoke('User.register', {
|
||||||
nickname,
|
nickname,
|
||||||
username,
|
username,
|
||||||
password,
|
password: crypto.createHash('sha256').update(password).digest('hex'),
|
||||||
})
|
})
|
||||||
if (re.code != 200)
|
if (re.code != 200)
|
||||||
throw new CallbackError(re)
|
throw new CallbackError(re)
|
||||||
|
return re.data!.user_id as string
|
||||||
}
|
}
|
||||||
async uploadFile({
|
async uploadFile({
|
||||||
chatId,
|
chatId,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import User from "./User.ts"
|
|||||||
import CallbackError from "./CallbackError.ts"
|
import CallbackError from "./CallbackError.ts"
|
||||||
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
||||||
|
|
||||||
import marked from 'marked'
|
import * as marked from 'marked'
|
||||||
|
import { text } from "node:stream/consumers";
|
||||||
|
|
||||||
class ChatMention extends BaseClientObject {
|
class ChatMention extends BaseClientObject {
|
||||||
declare chat_id?: string
|
declare chat_id?: string
|
||||||
@@ -61,9 +62,12 @@ class ChatAttachment extends BaseClientObject {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async blobOrThrow() {
|
fetch(init?: RequestInit) {
|
||||||
const url = this.client.getUrlForFileByHash(this.file_hash)
|
const url = this.client.getUrlForFileByHash(this.file_hash)
|
||||||
const re = await fetch(url!)
|
return fetch(url!, init)
|
||||||
|
}
|
||||||
|
async blobOrThrow() {
|
||||||
|
const re = await this.fetch()
|
||||||
const blob = await re.blob()
|
const blob = await re.blob()
|
||||||
if (!re.ok) throw new CallbackError({
|
if (!re.ok) throw new CallbackError({
|
||||||
msg: await blob.text(),
|
msg: await blob.text(),
|
||||||
@@ -71,6 +75,50 @@ class ChatAttachment extends BaseClientObject {
|
|||||||
} as ApiCallbackMessage)
|
} as ApiCallbackMessage)
|
||||||
return blob
|
return blob
|
||||||
}
|
}
|
||||||
|
async getMimeType() {
|
||||||
|
try {
|
||||||
|
return await this.getMimeTypeOrThrow()
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getMimeTypeOrThrow() {
|
||||||
|
const re = await this.fetch({
|
||||||
|
method: 'HEAD'
|
||||||
|
})
|
||||||
|
if (re.ok) {
|
||||||
|
const t = re.headers.get('content-type')
|
||||||
|
if (t)
|
||||||
|
return t
|
||||||
|
throw new Error("Unable to get Content-Type")
|
||||||
|
}
|
||||||
|
throw new CallbackError({
|
||||||
|
msg: await re.text(),
|
||||||
|
code: re.status,
|
||||||
|
} as ApiCallbackMessage)
|
||||||
|
}
|
||||||
|
async getLength() {
|
||||||
|
try {
|
||||||
|
return await this.getLengthOrThrow()
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getLengthOrThrow() {
|
||||||
|
const re = await this.fetch({
|
||||||
|
method: 'HEAD'
|
||||||
|
})
|
||||||
|
if (re.ok) {
|
||||||
|
const contentLength = re.headers.get('content-length')
|
||||||
|
if (contentLength)
|
||||||
|
return parseInt(contentLength)
|
||||||
|
throw new Error("Unable to get Content-Length")
|
||||||
|
}
|
||||||
|
throw new CallbackError({
|
||||||
|
msg: await re.text(),
|
||||||
|
code: re.status,
|
||||||
|
} as ApiCallbackMessage)
|
||||||
|
}
|
||||||
getFileHash() {
|
getFileHash() {
|
||||||
return this.file_hash
|
return this.file_hash
|
||||||
}
|
}
|
||||||
@@ -112,6 +160,22 @@ export default class Message extends BaseClientObject {
|
|||||||
return new marked.Marked({
|
return new marked.Marked({
|
||||||
async: false,
|
async: false,
|
||||||
extensions: [
|
extensions: [
|
||||||
|
{
|
||||||
|
name: 'text',
|
||||||
|
renderer: ({ text }) => text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'heading',
|
||||||
|
renderer({ tokens }) {
|
||||||
|
return this.parser.parseInline(tokens!)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'paragraph',
|
||||||
|
renderer({ tokens }) {
|
||||||
|
return this.parser.parseInline(tokens!)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'image',
|
name: 'image',
|
||||||
renderer: ({ text, href }) => {
|
renderer: ({ text, href }) => {
|
||||||
@@ -120,8 +184,8 @@ export default class Message extends BaseClientObject {
|
|||||||
|
|
||||||
if (fileType != null && /tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
|
if (fileType != null && /tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
|
||||||
const file_hash = /^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]!
|
const file_hash = /^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]!
|
||||||
let file_name: string = /^Video|File|Image=(.*)/.exec(text)?.[1] || text
|
let file_name: string = /^(Video|File|Image)=(.*)/.exec(text)?.[2] || text
|
||||||
file_name.trim() == '' && (file_name = 'Unnamed File')
|
file_name.trim() == '' && (file_name = 'Unnamed_File')
|
||||||
return attachment ? attachment({ text: text, attachment: new ChatAttachment(this.client, { file_hash, file_name }), fileType: fileType, }) : text
|
return attachment ? attachment({ text: text, attachment: new ChatAttachment(this.client, { file_hash, file_name }), fileType: fileType, }) : text
|
||||||
}
|
}
|
||||||
if (mentionType != null && /^tws:\/\/chat\?id=[A-Za-z0-9]+/.test(href)) {
|
if (mentionType != null && /^tws:\/\/chat\?id=[A-Za-z0-9]+/.test(href)) {
|
||||||
|
|||||||
13
client-protocol/RecentChat.ts
Normal file
13
client-protocol/RecentChat.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||||
|
import Chat from "./Chat.ts"
|
||||||
|
import LingChairClient from "./LingChairClient.ts"
|
||||||
|
|
||||||
|
export default class RecentChat extends Chat {
|
||||||
|
declare bean: RecentChatBean
|
||||||
|
constructor(client: LingChairClient, bean: RecentChatBean) {
|
||||||
|
super(client, bean)
|
||||||
|
}
|
||||||
|
getContent() {
|
||||||
|
return this.bean.content
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
static async getById(client: LingChairClient, id: string) {
|
||||||
try {
|
try {
|
||||||
return await this.getByIdOrThrow(client, id)
|
return await this.getByIdOrThrow(client, id)
|
||||||
} catch(_) {
|
} catch (_) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import CallbackError from "./CallbackError.ts"
|
import CallbackError from "./CallbackError.ts"
|
||||||
|
import Chat from "./Chat.ts"
|
||||||
import LingChairClient from "./LingChairClient.ts"
|
import LingChairClient from "./LingChairClient.ts"
|
||||||
|
import RecentChat from "./RecentChat.ts"
|
||||||
import User from "./User.ts"
|
import User from "./User.ts"
|
||||||
import ChatBean from "./bean/ChatBean.ts"
|
import ChatBean from "./bean/ChatBean.ts"
|
||||||
import RecentChatBean from "./bean/RecentChatBean.ts"
|
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||||
@@ -154,9 +156,19 @@ export default class UserMySelf extends User {
|
|||||||
token: this.client.access_token
|
token: this.client.access_token
|
||||||
})
|
})
|
||||||
if (re.code == 200)
|
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)
|
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[]
|
return re.data!.recent_chats as RecentChatBean[]
|
||||||
throw new CallbackError(re)
|
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[]
|
return re.data!.all_chats as ChatBean[]
|
||||||
throw new CallbackError(re)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ChatBean from "./ChatBean.ts"
|
import ChatBean from "./ChatBean.ts"
|
||||||
|
|
||||||
export default class RecentChat extends ChatBean {
|
export default class RecentChatBean extends ChatBean {
|
||||||
declare content: string
|
declare content: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
28
client-protocol/main.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Chat from "./Chat.ts"
|
||||||
|
import User from "./User.ts"
|
||||||
|
import UserMySelf from "./UserMySelf.ts"
|
||||||
|
import UserBean from "./bean/UserBean.ts"
|
||||||
|
import ChatBean from "./bean/ChatBean.ts"
|
||||||
|
import GroupSettingsBean from "./bean/GroupSettingsBean.ts"
|
||||||
|
import JoinRequestBean from "./bean/JoinRequestBean.ts"
|
||||||
|
import MessageBean from "./bean/MessageBean.ts"
|
||||||
|
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||||
|
|
||||||
|
import LingChairClient from "./LingChairClient.ts"
|
||||||
|
import CallbackError from "./CallbackError.ts"
|
||||||
|
|
||||||
|
export {
|
||||||
|
LingChairClient,
|
||||||
|
CallbackError,
|
||||||
|
|
||||||
|
Chat,
|
||||||
|
User,
|
||||||
|
UserMySelf,
|
||||||
|
|
||||||
|
UserBean,
|
||||||
|
ChatBean,
|
||||||
|
MessageBean,
|
||||||
|
RecentChatBean,
|
||||||
|
JoinRequestBean,
|
||||||
|
}
|
||||||
|
export type { GroupSettingsBean }
|
||||||
11
client-protocol/package.json
Normal file
11
client-protocol/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "lingchair-client-protocol",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./main.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"lingchair-internal-shared": "*",
|
||||||
|
"marked": "16.3.0",
|
||||||
|
"socket.io-client": "4.8.1",
|
||||||
|
"crypto-browserify": "3.12.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
client/ClientCache.ts
Normal file
23
client/ClientCache.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Chat, User } from "lingchair-client-protocol"
|
||||||
|
import getClient from "./getClient"
|
||||||
|
|
||||||
|
type CouldCached = User | Chat | null
|
||||||
|
export default class ClientCache {
|
||||||
|
static caches: { [key: string]: CouldCached } = {}
|
||||||
|
|
||||||
|
static async getUser(id: string) {
|
||||||
|
const k = 'user_' + id
|
||||||
|
if (this.caches[k] != null)
|
||||||
|
return this.caches[k] as User | null
|
||||||
|
this.caches[k] = await User.getById(getClient(), id)
|
||||||
|
return this.caches[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getChat(id: string) {
|
||||||
|
const k = 'chat_' + id
|
||||||
|
if (this.caches[k] != null)
|
||||||
|
return this.caches[k] as Chat | null
|
||||||
|
this.caches[k] = await Chat.getById(getClient(), id)
|
||||||
|
return this.caches[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export default class EventBus {
|
|
||||||
static events: { [key: string]: () => void } = {}
|
|
||||||
static on(eventName: string, func: () => void) {
|
|
||||||
this.events[eventName] = func
|
|
||||||
}
|
|
||||||
static off(eventName: string) {
|
|
||||||
delete this.events[eventName]
|
|
||||||
}
|
|
||||||
static emit(eventName: string) {
|
|
||||||
this.events[eventName]()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ApiCallbackMessage } from 'lingchair-internal-shared'
|
|
||||||
|
|
||||||
export type { ApiCallbackMessage as default }
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from 'lingchair-internal-shared'
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
type ChatType = 'private' | 'group'
|
|
||||||
|
|
||||||
export default ChatType
|
|
||||||
@@ -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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export default class JoinRequest {
|
|
||||||
declare user_id: string
|
|
||||||
declare title: string
|
|
||||||
declare avatar?: string
|
|
||||||
declare reason?: string
|
|
||||||
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default class Message {
|
|
||||||
declare id: number
|
|
||||||
declare text: string
|
|
||||||
declare user_id: string
|
|
||||||
declare time: string
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import Chat from "./Chat.ts"
|
|
||||||
|
|
||||||
export default class RecentChat extends Chat {
|
|
||||||
declare content: string
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default class User {
|
|
||||||
declare id: string
|
|
||||||
declare username?: string
|
|
||||||
declare nickname: string
|
|
||||||
declare avatar_file_hash?: string
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import process from 'node:process'
|
|
||||||
import child_process from 'node:child_process'
|
|
||||||
import fs from 'node:fs/promises'
|
|
||||||
|
|
||||||
function spawn(exec: string, args: string[]) {
|
|
||||||
child_process.spawnSync(exec, args, {
|
|
||||||
stdio: [process.stdin, process.stdout, process.stderr]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function runBuild() {
|
|
||||||
const args = [
|
|
||||||
"run",
|
|
||||||
"-A",
|
|
||||||
"--node-modules-dir",
|
|
||||||
]
|
|
||||||
let i = 0
|
|
||||||
for (const arg of process.argv) {
|
|
||||||
if (i > 1)
|
|
||||||
args.push(arg)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
spawn('deno', args)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform == 'android') {
|
|
||||||
try {
|
|
||||||
await fs.stat('./node_modules/.deno/rollup@4.50.1/node_modules/rollup/')
|
|
||||||
} catch (e) {
|
|
||||||
spawn('deno', ['install', '--node-modules-dir=auto'])
|
|
||||||
}
|
|
||||||
spawn('sh', ["fix-build-on-android.sh"])
|
|
||||||
}
|
|
||||||
|
|
||||||
runBuild()
|
|
||||||
71
client/data.ts
Normal file
71
client/data.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
const dataIsEmpty = !localStorage.tws_data || localStorage.tws_data == ''
|
||||||
|
|
||||||
|
class Aes {
|
||||||
|
static randomIv() {
|
||||||
|
return crypto.randomBytes(12)
|
||||||
|
}
|
||||||
|
static normalizeKey(key: string, keyLength = 32) {
|
||||||
|
const hash = crypto.createHash('sha256')
|
||||||
|
hash.update(key)
|
||||||
|
const keyBuffer = hash.digest()
|
||||||
|
return keyLength ? keyBuffer.subarray(0, keyLength) : keyBuffer
|
||||||
|
}
|
||||||
|
static encrypt(data: string, key: string) {
|
||||||
|
const iv = this.randomIv()
|
||||||
|
return Buffer.concat([iv, crypto.createCipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(data)]).toString('hex')
|
||||||
|
}
|
||||||
|
static decrypt(data: string, key: string) {
|
||||||
|
const buffer = Buffer.from(data, 'hex')
|
||||||
|
const iv = buffer.subarray(0, 12)
|
||||||
|
return crypto.createDecipheriv("aes-256-gcm", this.normalizeKey(key), iv).update(buffer.subarray(12)).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尽可能防止被窃取, 虽然理论上还是会被窃取
|
||||||
|
const key = crypto.createHash('sha256').update(location.host + '_TWS_姐姐_' + navigator.userAgent).digest().toString('base64')
|
||||||
|
|
||||||
|
if (dataIsEmpty) localStorage.tws_data = Aes.encrypt('{}', key)
|
||||||
|
|
||||||
|
let _data_cached
|
||||||
|
try {
|
||||||
|
_data_cached = JSON.parse(Aes.decrypt(localStorage.tws_data, key))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("数据解密失败, 使用空数据...", e)
|
||||||
|
_data_cached = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
type IData = {
|
||||||
|
refresh_token?: string
|
||||||
|
split_sizes: number[]
|
||||||
|
apply(): void
|
||||||
|
access_token?: string
|
||||||
|
device_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
data?: IData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Proxy({} as IData, {
|
||||||
|
get(_obj, k) {
|
||||||
|
if (k == '_cached') return _data_cached
|
||||||
|
if (k == 'apply') return () => localStorage.tws_data = Aes.encrypt(JSON.stringify(_data_cached), key)
|
||||||
|
return _data_cached[k]
|
||||||
|
},
|
||||||
|
set(_obj, k, v) {
|
||||||
|
if (k == '_cached') return false
|
||||||
|
_data_cached[k] = v
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (new URL(location.href).searchParams.get('export_data') == 'true') {
|
||||||
|
window.data = data
|
||||||
|
console.warn("警告: 将 data 暴露到 window 有可能会导致令牌泄露!")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default data
|
||||||
@@ -1,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
8
client/env.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="mdui/jsx.zh-cn.d.ts" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
declare const __GIT_HASH__: string
|
||||||
|
declare const __GIT_HASH_FULL__: string
|
||||||
|
declare const __GIT_BRANCH__: string
|
||||||
|
declare const __BUILD_TIME__: string
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export default function escapeHTML(str: string) {
|
|
||||||
const div = document.createElement('div')
|
|
||||||
div.textContent = str
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
rm -r ./node_modules/.deno/rollup@4.50.1/node_modules/rollup/
|
|
||||||
cp -r ./node_modules/.deno/@rollup+wasm-node@4.48.0/node_modules/@rollup/wasm-node/ node_modules/.deno/rollup@4.50.1/node_modules/rollup/
|
|
||||||
echo Replaced rollup with @rollup/wasm-node successfully
|
|
||||||
19
client/getClient.ts
Normal file
19
client/getClient.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { LingChairClient } from 'lingchair-client-protocol'
|
||||||
|
import data from "./data.ts"
|
||||||
|
import { UAParser } from 'ua-parser-js'
|
||||||
|
import { randomUUID } from 'lingchair-internal-shared'
|
||||||
|
import performAuth from './performAuth.ts'
|
||||||
|
|
||||||
|
if (!data.device_id) {
|
||||||
|
const ua = new UAParser(navigator.userAgent)
|
||||||
|
data.device_id = `LingChair_Web_${ua.getOS() || 'unknown-os'}-${ua.getDevice().type || 'unknown_device'}-${randomUUID()}`
|
||||||
|
}
|
||||||
|
const client = new LingChairClient({
|
||||||
|
server_url: '',
|
||||||
|
device_id: data.device_id,
|
||||||
|
auto_fresh_token: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function getClient() {
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function getUrlForFileByHash(file_hash?: string, defaultUrl?: string) {
|
|
||||||
return file_hash ? "uploaded_files/" + file_hash: defaultUrl
|
|
||||||
}
|
|
||||||
BIN
client/icon.ico
BIN
client/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 29 KiB |
@@ -18,10 +18,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
<script nomodule>
|
<script type="module" src="./init.ts"></script>
|
||||||
alert('很抱歉, 此应用无法在较旧的浏览器运行, 请使用基于 Chromium 89+ 的浏览器(内核)使用 :(')
|
|
||||||
</script>
|
|
||||||
<script type="module" src="./index.ts"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -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
41
client/init.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'mdui/mdui.css'
|
||||||
|
import 'mdui'
|
||||||
|
import { breakpoint } from "mdui"
|
||||||
|
|
||||||
|
import './env.d.ts'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import './ui/chat-elements/chat-image.ts'
|
||||||
|
import './ui/chat-elements/chat-video.ts'
|
||||||
|
import './ui/chat-elements/chat-file.ts'
|
||||||
|
import './ui/chat-elements/chat-text.ts'
|
||||||
|
import './ui/chat-elements/chat-mention.ts'
|
||||||
|
import './ui/chat-elements/chat-text-container.ts'
|
||||||
|
import './ui/chat-elements/chat-quote.ts'
|
||||||
|
import Main from "./ui/Main.tsx"
|
||||||
|
|
||||||
|
import performAuth from './performAuth.ts'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performAuth({})
|
||||||
|
} catch (e) {
|
||||||
|
console.log("验证失败", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(React.createElement(Main))
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
document.body.style.setProperty('--whitesilk-widget-message-maxwidth', breakpoint().down('md') ? "80%" : "70%")
|
||||||
|
// deno-lint-ignore no-window
|
||||||
|
document.body.style.setProperty('--whitesilk-window-width', window.innerWidth + 'px')
|
||||||
|
// deno-lint-ignore no-window
|
||||||
|
document.body.style.setProperty('--whitesilk-window-height', window.innerHeight + 'px')
|
||||||
|
}
|
||||||
|
// deno-lint-ignore no-window no-window-prefix
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
onResize()
|
||||||
|
|
||||||
|
const config = await fetch('config.json').then((re) => re.json())
|
||||||
|
config.title && (document.title = config.title)
|
||||||
32
client/package.json
Normal file
32
client/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "lingchair-client",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0-alpha",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npx vite build",
|
||||||
|
"build-watch": "npx vite --watch build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "3.2.7",
|
||||||
|
"lingchair-internal-shared": "*",
|
||||||
|
"marked": "16.3.0",
|
||||||
|
"mdui": "2.1.4",
|
||||||
|
"pinch-zoom-element": "1.1.1",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1",
|
||||||
|
"react-router": "7.10.1",
|
||||||
|
"socket.io-client": "4.8.1",
|
||||||
|
"split.js": "1.3.2",
|
||||||
|
"ua-parser-js": "2.0.6",
|
||||||
|
"use-context-selector": "2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/wasm-node": "4.48.0",
|
||||||
|
"@types/react": "18.3.1",
|
||||||
|
"@types/react-dom": "18.3.1",
|
||||||
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
|
"chalk": "5.4.1",
|
||||||
|
"vite": "7.2.6",
|
||||||
|
"vite-plugin-node-polyfills": "^0.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
client/performAuth.ts
Normal file
31
client/performAuth.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import data from "./data.ts"
|
||||||
|
import getClient from "./getClient.ts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进行身份验证以接受客户端事件
|
||||||
|
*
|
||||||
|
* 使用验证方式优先级: 访问 > 刷新 > 账号密码
|
||||||
|
*
|
||||||
|
* 若传递了账号密码, 则同时缓存新的访问令牌和刷新令牌
|
||||||
|
*
|
||||||
|
* 如只传递两个令牌的其一, 按照优先级并在成功验证后赋值
|
||||||
|
*
|
||||||
|
* 多个验证方式不会逐一尝试
|
||||||
|
*/
|
||||||
|
export default async function performAuth(args: {
|
||||||
|
refresh_token?: string
|
||||||
|
account?: string
|
||||||
|
password?: string
|
||||||
|
}) {
|
||||||
|
if (args.account && args.password)
|
||||||
|
await getClient().authOrThrow({
|
||||||
|
account: args.account,
|
||||||
|
password: args.password,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
await getClient().authOrThrow({ refresh_token: args.refresh_token ? args.refresh_token : data.refresh_token, ignore_all_empty: true })
|
||||||
|
}
|
||||||
|
data.refresh_token = getClient().getCachedRefreshToken()
|
||||||
|
data.access_token = getClient().getCachedAccessToken()
|
||||||
|
data.apply()
|
||||||
|
}
|
||||||
@@ -67,3 +67,7 @@ html {
|
|||||||
.gutter.gutter-horizontal {
|
.gutter.gutter-horizontal {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(var(--mdui-color-primary));
|
||||||
|
}
|
||||||
|
|||||||
30
client/sw.ts
30
client/sw.ts
@@ -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 在这里不可用
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN" class="mdui-theme-auto">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no" />
|
|
||||||
<meta name="renderer" content="webkit" />
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css">
|
|
||||||
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
|
|
||||||
|
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
|
||||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
|
||||||
|
|
||||||
<title>TheWhiteSilk Debugger</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<mdui-button id="send">Send</mdui-button>
|
|
||||||
<mdui-text-field id="edittext" autosize></mdui-text-field>
|
|
||||||
<div id="out">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const socket = io()
|
|
||||||
$('#edittext').val(`{
|
|
||||||
"method": "",
|
|
||||||
"args": {
|
|
||||||
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
$('#send').click(() => {
|
|
||||||
socket.emit("the_white_silk", JSON.parse($('#edittext').val()), (response) => {
|
|
||||||
$('#out').text(JSON.stringify(response))
|
|
||||||
});
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
18
client/tsconfig.json
Normal file
18
client/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,273 +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')
|
|
||||||
}}></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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,9 @@ export default function Avatar({
|
|||||||
avatarRef,
|
avatarRef,
|
||||||
...props
|
...props
|
||||||
}: Args) {
|
}: Args) {
|
||||||
if (src != null)
|
if (src != null && src != '')
|
||||||
return <mdui-avatar ref={avatarRef} {...props} src={src} />
|
return <mdui-avatar ref={avatarRef} {...props} src={src} />
|
||||||
else if (text != null)
|
else if (text != null && text != '')
|
||||||
return <mdui-avatar ref={avatarRef} {...props}>
|
return <mdui-avatar ref={avatarRef} {...props}>
|
||||||
{
|
{
|
||||||
text.substring(0, 1)
|
text.substring(0, 1)
|
||||||
|
|||||||
34
client/ui/AvatarMySelf.tsx
Normal file
34
client/ui/AvatarMySelf.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { UserMySelf } from "lingchair-client-protocol"
|
||||||
|
import useAsyncEffect from "../utils/useAsyncEffect.ts"
|
||||||
|
import Avatar from "./Avatar.tsx"
|
||||||
|
import getClient from "../getClient.ts"
|
||||||
|
import React from "react"
|
||||||
|
import sleep from "../utils/sleep.ts"
|
||||||
|
|
||||||
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
avatarRef?: React.LegacyRef<HTMLElement>
|
||||||
|
}
|
||||||
|
export default function AvatarMySelf({
|
||||||
|
avatarRef,
|
||||||
|
...props
|
||||||
|
}: Args) {
|
||||||
|
if (!avatarRef) avatarRef = React.useRef<HTMLElement>(null)
|
||||||
|
const [args, setArgs] = React.useState<{
|
||||||
|
text: string,
|
||||||
|
src: string,
|
||||||
|
}>({
|
||||||
|
text: '',
|
||||||
|
src: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
await sleep(200)
|
||||||
|
const mySelf = await UserMySelf.getMySelfOrThrow(getClient())
|
||||||
|
setArgs({
|
||||||
|
text: mySelf.getNickName(),
|
||||||
|
src: getClient().getUrlForFileByHash(mySelf.getAvatarFileHash(), '')!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <Avatar avatarRef={avatarRef} {...props} text={args.text} src={args.src}></Avatar>
|
||||||
|
}
|
||||||
19
client/ui/ImageViewer.tsx
Normal file
19
client/ui/ImageViewer.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Dialog } from 'mdui'
|
||||||
|
import 'pinch-zoom-element'
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export default function ImageViewer() {
|
||||||
|
const dialogRef = React.useRef<Dialog>()
|
||||||
|
|
||||||
|
return <mdui-dialog ref={dialogRef} fullscreen="fullscreen">
|
||||||
|
<mdui-button-icon icon="open_in_new"
|
||||||
|
onclick="window.open(document.querySelector('#image-viewer-dialog-inner > *').src, '_blank')">
|
||||||
|
</mdui-button-icon>
|
||||||
|
<mdui-button-icon icon="close" onClick={() => dialogRef.current!.open = false}>
|
||||||
|
</mdui-button-icon>
|
||||||
|
{
|
||||||
|
// @ts-ignore 注册了这个元素
|
||||||
|
<pinch-zoom id="image-viewer-dialog-inner" style="width: var(--whitesilk-window-width); height: var(--whitesilk-window-height);"></pinch-zoom>
|
||||||
|
}
|
||||||
|
</mdui-dialog>
|
||||||
|
}
|
||||||
231
client/ui/Main.tsx
Normal file
231
client/ui/Main.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import isMobileUI from "../utils/isMobileUI.ts"
|
||||||
|
import useEventListener from "../utils/useEventListener.ts"
|
||||||
|
import AvatarMySelf from "./AvatarMySelf.tsx"
|
||||||
|
import MainSharedContext from './MainSharedContext.ts'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { BrowserRouter, Link, Outlet, Route, Routes } from "react-router"
|
||||||
|
import LoginDialog from "./main-page/LoginDialog.tsx"
|
||||||
|
import useAsyncEffect from "../utils/useAsyncEffect.ts"
|
||||||
|
import performAuth from "../performAuth.ts"
|
||||||
|
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
||||||
|
import showCircleProgressDialog from "./showCircleProgressDialog.ts"
|
||||||
|
import RegisterDialog from "./main-page/RegisterDialog.tsx"
|
||||||
|
import sleep from "../utils/sleep.ts"
|
||||||
|
import { $, NavigationDrawer } from "mdui"
|
||||||
|
import getClient from "../getClient.ts"
|
||||||
|
import showSnackbar from "../utils/showSnackbar.ts"
|
||||||
|
import AllChatsList from "./main-page/AllChatsList.tsx"
|
||||||
|
import FavouriteChatsList from "./main-page/FavouriteChatsList.tsx"
|
||||||
|
import AddFavourtieChatDialog from "./main-page/AddFavourtieChatDialog.tsx"
|
||||||
|
import RecentChatsList from "./main-page/RecentChatsList.tsx"
|
||||||
|
import ChatInfoDialog from "./routers/ChatInfoDialog.tsx"
|
||||||
|
|
||||||
|
export default function Main() {
|
||||||
|
const [myProfileCache, setMyProfileCache] = React.useState<UserMySelf>()
|
||||||
|
|
||||||
|
// 多页面切换
|
||||||
|
const navigationRef = React.useRef<HTMLElement>()
|
||||||
|
const [currentShowPage, setCurrentShowPage] = React.useState('Recents')
|
||||||
|
type HTMLElementWithValue = HTMLElement & { value: string }
|
||||||
|
useEventListener(navigationRef, 'change', (event) => {
|
||||||
|
setCurrentShowPage((event.target as HTMLElementWithValue).value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerRef = React.useRef<NavigationDrawer>()
|
||||||
|
React.useEffect(() => {
|
||||||
|
$(drawerRef.current!.shadowRoot).append(`
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
width: 17.5rem !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [showLoginDialog, setShowLoginDialog] = React.useState(false)
|
||||||
|
const [showRegisterDialog, setShowRegisterDialog] = React.useState(false)
|
||||||
|
const [showAddFavourtieChatDialog, setShowAddFavourtieChatDialog] = React.useState(false)
|
||||||
|
|
||||||
|
const [currentSelectedChatId, setCurrentSelectedChatId] = React.useState('')
|
||||||
|
|
||||||
|
const [favouriteChats, setFavouriteChats] = React.useState<Chat[]>([])
|
||||||
|
|
||||||
|
const sharedContext = {
|
||||||
|
functions_lazy: React.useRef({
|
||||||
|
updateFavouriteChats: () => { },
|
||||||
|
updateRecentChats: () => { },
|
||||||
|
updateAllChats: () => { },
|
||||||
|
}),
|
||||||
|
favouriteChats,
|
||||||
|
setFavouriteChats,
|
||||||
|
|
||||||
|
setShowLoginDialog,
|
||||||
|
setShowRegisterDialog,
|
||||||
|
setShowAddFavourtieChatDialog,
|
||||||
|
|
||||||
|
currentSelectedChatId,
|
||||||
|
setCurrentSelectedChatId,
|
||||||
|
|
||||||
|
myProfileCache,
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
const waitingForAuth = showCircleProgressDialog("加载中...")
|
||||||
|
try {
|
||||||
|
await performAuth({})
|
||||||
|
|
||||||
|
try {
|
||||||
|
setMyProfileCache(await UserMySelf.getMySelfOrThrow(getClient()))
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '获取资料失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
if (e.code == 401 || e.code == 400)
|
||||||
|
setShowLoginDialog(true)
|
||||||
|
}
|
||||||
|
// 动画都没来得及, 稍微等一下 (
|
||||||
|
await sleep(100)
|
||||||
|
waitingForAuth.open = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const subRoutes = <>
|
||||||
|
<Route path="/info">
|
||||||
|
<Route path="chat" element={<ChatInfoDialog />} />
|
||||||
|
<Route path="user" element={<ChatInfoDialog />} />
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainSharedContext.Provider value={sharedContext}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={(
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
position: 'relative',
|
||||||
|
flexDirection: isMobileUI() ? 'column' : 'row',
|
||||||
|
width: `calc(var(--whitesilk-window-width))${isMobileUI() ? '' : ' - 80px'}`,
|
||||||
|
height: 'var(--whitesilk-window-height)',
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
// 将子路由渲染到此处
|
||||||
|
<Outlet />
|
||||||
|
}
|
||||||
|
<LoginDialog open={showLoginDialog} />
|
||||||
|
<RegisterDialog open={showRegisterDialog} />
|
||||||
|
<AddFavourtieChatDialog open={showAddFavourtieChatDialog} />
|
||||||
|
<mdui-navigation-drawer ref={drawerRef} modal close-on-esc close-on-overlay-click>
|
||||||
|
<mdui-list style={{
|
||||||
|
padding: '10px',
|
||||||
|
}}>
|
||||||
|
<mdui-list-item rounded>
|
||||||
|
<span>{myProfileCache?.getNickName()}</span>
|
||||||
|
<AvatarMySelf slot="icon" />
|
||||||
|
</mdui-list-item>
|
||||||
|
<mdui-list-item rounded icon="manage_accounts">账号设置</mdui-list-item>
|
||||||
|
<mdui-divider style={{
|
||||||
|
margin: '10px',
|
||||||
|
}}></mdui-divider>
|
||||||
|
<mdui-list-item rounded icon="person_add">添加收藏对话</mdui-list-item>
|
||||||
|
<mdui-list-item rounded icon="group_add">创建新的群组</mdui-list-item>
|
||||||
|
<Link to="/info/user?id=0960bd15-4527-4000-97a8-73110160296f"><mdui-list-item rounded icon="group_add">我是测试</mdui-list-item></Link>
|
||||||
|
<Link to="/info/chat?id=priv_0960bd15_4527_4000_97a8_73110160296f__0960bd15_4527_4000_97a8_73110160296f"><mdui-list-item rounded icon="group_add">我是测试2</mdui-list-item></Link>
|
||||||
|
</mdui-list>
|
||||||
|
<div style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
}}></div>
|
||||||
|
<span style={{
|
||||||
|
padding: '10px',
|
||||||
|
fontSize: 'small',
|
||||||
|
}}>
|
||||||
|
LingChair Web v{__APP_VERSION__}<br />
|
||||||
|
Build: <a href={`https://codeberg.org/CrescentLeaf/LingChair/src/commit/${__GIT_HASH_FULL__}`}>{__GIT_HASH__}</a> ({__BUILD_TIME__})<br />
|
||||||
|
在 Codeberg 上<a href="https://codeberg.org/CrescentLeaf/LingChair">查看源代码</a>
|
||||||
|
</span>
|
||||||
|
</mdui-navigation-drawer>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Default: 侧边列表提供列表切换
|
||||||
|
*/
|
||||||
|
!isMobileUI() ?
|
||||||
|
<mdui-navigation-rail ref={navigationRef} contained value="Recents">
|
||||||
|
<mdui-button-icon slot="top" icon="menu" onClick={() => drawerRef.current!.open = true}></mdui-button-icon>
|
||||||
|
|
||||||
|
<mdui-navigation-rail-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents"></mdui-navigation-rail-item>
|
||||||
|
<mdui-navigation-rail-item icon="favorite_border" active-icon="favorite" value="Favourites"></mdui-navigation-rail-item>
|
||||||
|
<mdui-navigation-rail-item icon="chat--outlined" active-icon="chat--filled" value="AllChats"></mdui-navigation-rail-item>
|
||||||
|
</mdui-navigation-rail>
|
||||||
|
/**
|
||||||
|
* Mobile: 底部导航栏提供列表切换
|
||||||
|
*/
|
||||||
|
: <mdui-top-app-bar style={{
|
||||||
|
position: 'sticky',
|
||||||
|
marginTop: '3px',
|
||||||
|
marginRight: '6px',
|
||||||
|
marginLeft: '15px',
|
||||||
|
top: '0px',
|
||||||
|
}}>
|
||||||
|
<mdui-button-icon icon="menu" onClick={() => drawerRef.current!.open = true}></mdui-button-icon>
|
||||||
|
<mdui-top-app-bar-title>{
|
||||||
|
({
|
||||||
|
Recents: "最近对话",
|
||||||
|
Favourites: "收藏对话",
|
||||||
|
AllChats: "所有对话",
|
||||||
|
})[currentShowPage]
|
||||||
|
}</mdui-top-app-bar-title>
|
||||||
|
<div style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
}}></div>
|
||||||
|
</mdui-top-app-bar>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mobile: 指定高度的容器
|
||||||
|
* Default: 侧边列表
|
||||||
|
*/
|
||||||
|
<div style={isMobileUI() ? {
|
||||||
|
display: 'flex',
|
||||||
|
height: 'calc(100% - 80px - 67px)',
|
||||||
|
width: '100%',
|
||||||
|
} : {}} id="SideBar">
|
||||||
|
<RecentChatsList style={{
|
||||||
|
display: currentShowPage == 'Recents' ? undefined : 'none'
|
||||||
|
}} />
|
||||||
|
<FavouriteChatsList style={{
|
||||||
|
display: currentShowPage == 'Favourites' ? undefined : 'none'
|
||||||
|
}} />
|
||||||
|
<AllChatsList style={{
|
||||||
|
display: currentShowPage == 'AllChats' ? undefined : 'none'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mobile: 底部导航栏提供列表切换
|
||||||
|
* Default: 侧边列表提供列表切换
|
||||||
|
*/
|
||||||
|
isMobileUI() && <mdui-navigation-bar ref={navigationRef} label-visibility="selected" value="Recents" style={{
|
||||||
|
position: 'sticky',
|
||||||
|
bottom: '0',
|
||||||
|
}}>
|
||||||
|
<mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents">最近对话</mdui-navigation-bar-item>
|
||||||
|
<mdui-navigation-bar-item icon="favorite_border" active-icon="favorite" value="Favourites">收藏对话</mdui-navigation-bar-item>
|
||||||
|
<mdui-navigation-bar-item icon="chat--outlined" active-icon="chat--filled" value="AllChats">全部对话</mdui-navigation-bar-item>
|
||||||
|
</mdui-navigation-bar>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}>
|
||||||
|
{subRoutes}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</MainSharedContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
client/ui/MainSharedContext.ts
Normal file
23
client/ui/MainSharedContext.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Chat, UserMySelf } from "lingchair-client-protocol"
|
||||||
|
import { createContext } from "use-context-selector"
|
||||||
|
|
||||||
|
type Shared = {
|
||||||
|
functions_lazy: React.MutableRefObject<{
|
||||||
|
updateFavouriteChats: () => void
|
||||||
|
updateRecentChats: () => void
|
||||||
|
updateAllChats: () => void
|
||||||
|
}>
|
||||||
|
favouriteChats: Chat[]
|
||||||
|
setFavouriteChats: React.Dispatch<React.SetStateAction<Chat[]>>
|
||||||
|
setShowLoginDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
setShowRegisterDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
setShowAddFavourtieChatDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
setCurrentSelectedChatId: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
myProfileCache?: UserMySelf
|
||||||
|
currentSelectedChatId: string
|
||||||
|
}
|
||||||
|
const MainSharedContext = createContext({} as Shared)
|
||||||
|
|
||||||
|
export default MainSharedContext
|
||||||
|
|
||||||
|
export type { Shared }
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
39
client/ui/chat-elements/chat-file.ts
Normal file
39
client/ui/chat-elements/chat-file.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { $ } from 'mdui/jq'
|
||||||
|
|
||||||
|
customElements.define('chat-file', class extends HTMLElement {
|
||||||
|
static observedAttributes = ['href', 'name']
|
||||||
|
declare anchor: HTMLAnchorElement
|
||||||
|
declare span: HTMLSpanElement
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
if (this.anchor == null) return
|
||||||
|
|
||||||
|
this.anchor.href = $(this).attr('href') as string
|
||||||
|
this.anchor.download = $(this).attr('href') as string
|
||||||
|
this.span.textContent = $(this).attr("name") as string
|
||||||
|
}
|
||||||
|
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||||
|
this.update()
|
||||||
|
}
|
||||||
|
connectedCallback() {
|
||||||
|
this.anchor = new DOMParser().parseFromString(`
|
||||||
|
<a style="width: 100%;height: 100%;">
|
||||||
|
<mdui-card clickable style="display: flex;align-items: center;box-shadow: inherit;border-radius: inherit;">
|
||||||
|
<mdui-icon name="insert_drive_file" style="margin: 13px;font-size: 34px;"></mdui-icon>
|
||||||
|
<span style="margin-right: 13px; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
|
||||||
|
</mdui-card>
|
||||||
|
</a>`, 'text/html').body.firstChild as HTMLAnchorElement
|
||||||
|
this.span = $(this.anchor).find('span').get(0)
|
||||||
|
this.anchor.style.textDecoration = 'none'
|
||||||
|
this.anchor.style.color = 'inherit'
|
||||||
|
this.anchor.onclick = (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
this.shadowRoot!.appendChild(this.anchor)
|
||||||
|
|
||||||
|
this.update()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import openImageViewer from "../openImageViewer.ts"
|
import openImageViewer from "../../utils/openImageViewer.ts"
|
||||||
|
|
||||||
import { $ } from 'mdui/jq'
|
import { $ } from 'mdui/jq'
|
||||||
|
|
||||||
@@ -56,37 +56,3 @@ customElements.define('chat-image', class extends HTMLElement {
|
|||||||
this.update()
|
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)
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { $ } from 'mdui'
|
import { $ } from 'mdui'
|
||||||
import DataCaches from "../../api/DataCaches.ts"
|
import showSnackbar from "../../utils/showSnackbar.ts";
|
||||||
import { snackbar } from "../snackbar.ts"
|
|
||||||
|
|
||||||
customElements.define('chat-mention', class extends HTMLElement {
|
customElements.define('chat-mention', class extends HTMLElement {
|
||||||
declare link: HTMLAnchorElement
|
declare link: HTMLAnchorElement
|
||||||
static observedAttributes = ['user-id']
|
static observedAttributes = ['user-id']
|
||||||
@@ -32,20 +30,18 @@ customElements.define('chat-mention', class extends HTMLElement {
|
|||||||
const text = $(this).attr('text')
|
const text = $(this).attr('text')
|
||||||
this.link.style.fontStyle = ''
|
this.link.style.fontStyle = ''
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
const chat = await DataCaches.getChatInfo(chatId)
|
|
||||||
this.link.textContent = chat?.title
|
|
||||||
this.link.onclick = (e) => {
|
this.link.onclick = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// deno-lint-ignore no-window
|
// deno-lint-ignore no-window
|
||||||
window.openChatInfoDialog(chat)
|
|
||||||
}
|
}
|
||||||
} else if (userId) {
|
} else if (userId) {
|
||||||
const user = await DataCaches.getUserProfile(userId)
|
|
||||||
this.link.textContent = user?.nickname
|
|
||||||
this.link.onclick = (e) => {
|
this.link.onclick = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// deno-lint-ignore no-window
|
// 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.style.fontStyle = 'italic'
|
||||||
this.link.onclick = (e) => {
|
this.link.onclick = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
snackbar({
|
showSnackbar({
|
||||||
message: "该提及没有指定用户或者对话!",
|
message: "该提及没有指定用户或者对话!",
|
||||||
placement: 'top',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
client/ui/chat-elements/chat-quote.ts
Normal file
45
client/ui/chat-elements/chat-quote.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { $ } from 'mdui/jq'
|
||||||
|
|
||||||
|
customElements.define('chat-quote', class extends HTMLElement {
|
||||||
|
declare container: HTMLAnchorElement
|
||||||
|
declare span: HTMLSpanElement
|
||||||
|
declare ellipsis: boolean
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
if (this.container == null) return
|
||||||
|
|
||||||
|
this.span.textContent = this.textContent
|
||||||
|
|
||||||
|
this.updateStyle()
|
||||||
|
}
|
||||||
|
updateStyle() {
|
||||||
|
this.span.style.whiteSpace = this.ellipsis ? 'nowrap' : 'pre-wrap'
|
||||||
|
this.span.style.overflow = this.ellipsis ? 'hidden' : ''
|
||||||
|
this.span.style.textOverflow = this.ellipsis ? 'ellipsis' : ''
|
||||||
|
}
|
||||||
|
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||||
|
this.update()
|
||||||
|
}
|
||||||
|
connectedCallback() {
|
||||||
|
this.container = new DOMParser().parseFromString(`
|
||||||
|
<a style="width: 100%;height: 100%; color: rgb(var(--mdui-color-primary));" href="javascript:void(0)">
|
||||||
|
<span style="display: block; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
|
||||||
|
</a>`, 'text/html').body.firstChild as HTMLAnchorElement
|
||||||
|
this.span = $(this.container).find('span').get(0)
|
||||||
|
this.container.style.textDecoration = 'none'
|
||||||
|
this.span.style.fontSynthesis = 'style weight'
|
||||||
|
this.container.onclick = (e) => {
|
||||||
|
this.ellipsis = !this.ellipsis
|
||||||
|
this.updateStyle()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
this.ellipsis = true
|
||||||
|
|
||||||
|
this.shadowRoot!.appendChild(this.container)
|
||||||
|
|
||||||
|
this.update()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,682 +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(``)
|
|
||||||
else if (type.startsWith('video/'))
|
|
||||||
insertText(``)
|
|
||||||
else
|
|
||||||
insertText(``)
|
|
||||||
}
|
|
||||||
useEventListener(attachFileInputRef, 'change', (_e) => {
|
|
||||||
const files = attachFileInputRef.current!.files as unknown as File[]
|
|
||||||
if (files?.length == 0) return
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
addFile(file.type, file.name, file)
|
|
||||||
}
|
|
||||||
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">
|
|
||||||
</mdui-tab-panel>
|
|
||||||
</mdui-tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
interface Args extends React.HTMLAttributes<HTMLElement> {}
|
|
||||||
|
|
||||||
export default function MessageContainer({ children, style, ...props }: Args) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '20px',
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { Button, Dialog, TextField } from "mdui";
|
|
||||||
import useEventListener from "../useEventListener.ts";
|
|
||||||
import Client from "../../api/Client.ts";
|
|
||||||
import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts";
|
|
||||||
|
|
||||||
import * as CryptoJS from 'crypto-js'
|
|
||||||
|
|
||||||
interface Refs {
|
|
||||||
loginInputAccountRef: React.MutableRefObject<TextField | null>
|
|
||||||
loginInputPasswordRef: React.MutableRefObject<TextField | null>
|
|
||||||
registerInputUserNameRef: React.MutableRefObject<TextField | null>
|
|
||||||
registerInputNickNameRef: React.MutableRefObject<TextField | null>
|
|
||||||
registerInputPasswordRef: React.MutableRefObject<TextField | null>
|
|
||||||
registerDialogRef: React.MutableRefObject<Dialog | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RegisterDialog({
|
|
||||||
loginInputAccountRef,
|
|
||||||
loginInputPasswordRef,
|
|
||||||
registerInputUserNameRef,
|
|
||||||
registerInputNickNameRef,
|
|
||||||
registerInputPasswordRef,
|
|
||||||
registerDialogRef
|
|
||||||
}: Refs) {
|
|
||||||
const registerBackButtonRef = React.useRef<Button>(null)
|
|
||||||
const doRegisterButtonRef = React.useRef<Button>(null)
|
|
||||||
useEventListener(registerBackButtonRef, 'click', () => registerDialogRef.current!.open = false)
|
|
||||||
useEventListener(doRegisterButtonRef, 'click', async () => {
|
|
||||||
const username = registerInputUserNameRef.current!.value
|
|
||||||
const re = await Client.invoke("User.register", {
|
|
||||||
username: username,
|
|
||||||
nickname: registerInputNickNameRef.current!.value,
|
|
||||||
password: CryptoJS.SHA256(registerInputPasswordRef.current!.value).toString(CryptoJS.enc.Hex),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (checkApiSuccessOrSncakbar(re, "注册失败")) return
|
|
||||||
loginInputAccountRef.current!.value = username == "" ? re.data!.userid as string : username
|
|
||||||
loginInputPasswordRef.current!.value = registerInputPasswordRef.current!.value
|
|
||||||
|
|
||||||
registerInputUserNameRef.current!.value = ""
|
|
||||||
registerInputNickNameRef.current!.value = ""
|
|
||||||
registerInputPasswordRef.current!.value = ""
|
|
||||||
registerDialogRef.current!.open = false
|
|
||||||
snackbar({
|
|
||||||
message: "注册成功!",
|
|
||||||
placement: "top",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<mdui-dialog headline="注册" ref={registerDialogRef}>
|
|
||||||
|
|
||||||
<mdui-text-field label="用户名 (可选)" ref={registerInputUserNameRef as any}></mdui-text-field>
|
|
||||||
<div style={{
|
|
||||||
height: "10px",
|
|
||||||
}}></div>
|
|
||||||
<mdui-text-field label="昵称" ref={registerInputNickNameRef as any}></mdui-text-field>
|
|
||||||
<div style={{
|
|
||||||
height: "10px",
|
|
||||||
}}></div>
|
|
||||||
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef as any}></mdui-text-field>
|
|
||||||
|
|
||||||
<mdui-button slot="action" variant="text" ref={registerBackButtonRef}>返回</mdui-button>
|
|
||||||
<mdui-button slot="action" variant="text" ref={doRegisterButtonRef}>注册</mdui-button>
|
|
||||||
</mdui-dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function isMobileUI() {
|
|
||||||
return new URL(location.href).searchParams.get('mobile') == 'true' || /Mobi|Android|iPhone/i.test(navigator.userAgent)
|
|
||||||
}
|
|
||||||
46
client/ui/main-page/AddFavourtieChatDialog.tsx
Normal file
46
client/ui/main-page/AddFavourtieChatDialog.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Button, Dialog, snackbar, TextField } from "mdui"
|
||||||
|
import { data } from 'react-router'
|
||||||
|
import { useContextSelector } from 'use-context-selector'
|
||||||
|
import MainSharedContext, { Shared } from '../MainSharedContext'
|
||||||
|
import showSnackbar from '../../utils/showSnackbar'
|
||||||
|
import { CallbackError } from 'lingchair-client-protocol'
|
||||||
|
import useEventListener from '../../utils/useEventListener'
|
||||||
|
|
||||||
|
export default function AddFavourtieChatDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||||
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
myProfileCache: context.myProfileCache,
|
||||||
|
setShowAddFavourtieChatDialog: context.setShowAddFavourtieChatDialog,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dialogRef = React.useRef<Dialog>()
|
||||||
|
useEventListener(dialogRef, 'closed', () => shared.setShowAddFavourtieChatDialog(false))
|
||||||
|
|
||||||
|
const inputTargetRef = React.useRef<TextField>(null)
|
||||||
|
|
||||||
|
async function addFavouriteChat() {
|
||||||
|
try {
|
||||||
|
shared.myProfileCache!.addFavouriteChatsOrThrow([inputTargetRef.current!.value])
|
||||||
|
inputTargetRef.current!.value = ''
|
||||||
|
showSnackbar({
|
||||||
|
message: '添加成功!'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '添加收藏对话失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-dialog close-on-overlay-click close-on-esc headline="添加收藏对话" {...props} ref={dialogRef}>
|
||||||
|
<mdui-text-field clearable label="对话 / 用户 (ID 或 别名)" ref={inputTargetRef} onKeyDown={(event: KeyboardEvent) => {
|
||||||
|
if (event.key == 'Enter')
|
||||||
|
addFavouriteChat()
|
||||||
|
}}></mdui-text-field>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => shared.setShowAddFavourtieChatDialog(false)}>取消</mdui-button>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => addFavouriteChat()}>添加</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
client/ui/main-page/AllChatsList.tsx
Normal file
80
client/ui/main-page/AllChatsList.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { TextField } from "mdui"
|
||||||
|
import React from "react"
|
||||||
|
import AllChatsListItem from "./AllChatsListItem.tsx"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
import { useContextSelector } from "use-context-selector"
|
||||||
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
|
||||||
|
export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
myProfileCache: context.myProfileCache,
|
||||||
|
functions_lazy: context.functions_lazy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const searchRef = React.useRef<HTMLElement>(null)
|
||||||
|
const [searchText, setSearchText] = React.useState('')
|
||||||
|
const [allChatsList, setAllChatsList] = React.useState<Chat[]>([])
|
||||||
|
|
||||||
|
useEventListener(searchRef, 'input', (e) => {
|
||||||
|
setSearchText((e.target as unknown as TextField).value)
|
||||||
|
})
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
async function updateAllChats() {
|
||||||
|
try {
|
||||||
|
setAllChatsList(await shared.myProfileCache!.getMyAllChatsOrThrow())
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
if (e.code != 401 && e.code != 400)
|
||||||
|
showSnackbar({
|
||||||
|
message: '获取所有对话失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAllChats()
|
||||||
|
|
||||||
|
shared.functions_lazy.current.updateAllChats = updateAllChats
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <mdui-list style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: '10px',
|
||||||
|
paddingLeft: '10px',
|
||||||
|
paddingTop: '0',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
...props?.style,
|
||||||
|
}} {...props}>
|
||||||
|
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||||
|
paddingTop: '12px',
|
||||||
|
paddingBottom: '13px',
|
||||||
|
position: 'sticky',
|
||||||
|
top: '0',
|
||||||
|
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||||
|
zIndex: '10',
|
||||||
|
}}></mdui-text-field>
|
||||||
|
{
|
||||||
|
allChatsList.filter((chat) =>
|
||||||
|
searchText == '' ||
|
||||||
|
chat.getTitle().includes(searchText) ||
|
||||||
|
chat.getId().includes(searchText)
|
||||||
|
).map((v) =>
|
||||||
|
<AllChatsListItem
|
||||||
|
active={isMobileUI() ? false : currentChatId == v.getId()}
|
||||||
|
key={v.getId()}
|
||||||
|
onClick={() => {
|
||||||
|
openChatInfoDialog(v)
|
||||||
|
}}
|
||||||
|
chat={v} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</mdui-list>
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { $ } from "mdui/jq"
|
import { $ } from "mdui/jq"
|
||||||
import Avatar from "../Avatar.tsx"
|
import Avatar from "../Avatar.tsx"
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
|
import { Chat } from "lingchair-client-protocol"
|
||||||
import Chat from "../../api/client_data/Chat.ts"
|
import getClient from "../../getClient.ts"
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
chat: Chat
|
chat: Chat
|
||||||
@@ -10,7 +10,7 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AllChatsListItem({ chat, active, ...prop }: Args) {
|
export default function AllChatsListItem({ chat, active, ...prop }: Args) {
|
||||||
const { title, avatar_file_hash } = chat
|
const title = chat.getTitle()
|
||||||
|
|
||||||
const ref = React.useRef<HTMLElement>(null)
|
const ref = React.useRef<HTMLElement>(null)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default function AllChatsListItem({ chat, active, ...prop }: Args) {
|
|||||||
<span style={{
|
<span style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}>{title}</span>
|
}}>{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>
|
</mdui-list-item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
170
client/ui/main-page/FavouriteChatsList.tsx
Normal file
170
client/ui/main-page/FavouriteChatsList.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import React from "react"
|
||||||
|
import FavouriteChatsListItem from "./FavouriteChatsListItem.tsx"
|
||||||
|
import { dialog, TextField } from "mdui"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
||||||
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import { useContextSelector } from "use-context-selector"
|
||||||
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
|
||||||
|
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
myProfileCache: context.myProfileCache,
|
||||||
|
setShowAddFavourtieChatDialog: context.setShowAddFavourtieChatDialog,
|
||||||
|
functions_lazy: context.functions_lazy,
|
||||||
|
currentSelectedChatId: context.currentSelectedChatId,
|
||||||
|
values_lazy: context.values_lazy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const searchRef = React.useRef<HTMLElement>(null)
|
||||||
|
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
|
||||||
|
const [searchText, setSearchText] = React.useState('')
|
||||||
|
const [favouriteChatsList, setFavouriteChatsList] = React.useState<Chat[]>([])
|
||||||
|
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
|
||||||
|
|
||||||
|
useEventListener(searchRef, 'input', (e) => {
|
||||||
|
setSearchText((e.target as unknown as TextField).value)
|
||||||
|
})
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
async function updateFavouriteChats() {
|
||||||
|
try {
|
||||||
|
const ls = await shared.myProfileCache!.getMyFavouriteChatsOrThrow()
|
||||||
|
setFavouriteChatsList(ls)
|
||||||
|
shared.favourite_chats
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
if (e.code != 401 && e.code != 400)
|
||||||
|
showSnackbar({
|
||||||
|
message: '获取收藏对话失败: ' + e.message
|
||||||
|
})
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateFavouriteChats()
|
||||||
|
|
||||||
|
shared.functions_lazy.current.updateFavouriteChats = updateFavouriteChats
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
}
|
||||||
|
}, [shared.myProfileCache])
|
||||||
|
|
||||||
|
return <mdui-list style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingLeft: '10px',
|
||||||
|
paddingRight: '10px',
|
||||||
|
paddingTop: '0',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
...props?.style,
|
||||||
|
}} {...props}>
|
||||||
|
<div style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: '0',
|
||||||
|
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||||
|
zIndex: '10',
|
||||||
|
}}>
|
||||||
|
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||||
|
paddingTop: '12px',
|
||||||
|
}}></mdui-text-field>
|
||||||
|
<mdui-list-item rounded style={{
|
||||||
|
marginTop: '13px',
|
||||||
|
width: '100%',
|
||||||
|
}} icon="person_add" onClick={() => shared.setShowAddFavourtieChatDialog(true)}>添加收藏</mdui-list-item>
|
||||||
|
<mdui-list-item rounded style={{
|
||||||
|
width: '100%',
|
||||||
|
}} icon="refresh" onClick={() => shared.functions_lazy.current.updateFavouriteChats()}>刷新列表</mdui-list-item>
|
||||||
|
<mdui-list-item rounded style={{
|
||||||
|
width: '100%',
|
||||||
|
}} icon={isMultiSelecting ? "done" : "edit"} onClick={() => {
|
||||||
|
if (isMultiSelecting)
|
||||||
|
setCheckedList({})
|
||||||
|
setIsMultiSelecting(!isMultiSelecting)
|
||||||
|
}}>{isMultiSelecting ? "关闭多选" : "多选模式"}</mdui-list-item>
|
||||||
|
{
|
||||||
|
isMultiSelecting && <>
|
||||||
|
<mdui-list-item rounded style={{
|
||||||
|
width: '100%',
|
||||||
|
}} icon="delete" onClick={() => dialog({
|
||||||
|
headline: "移除收藏对话",
|
||||||
|
description: "确定将所选对话从收藏中移除吗? 这不会导致对话被删除.",
|
||||||
|
closeOnEsc: true,
|
||||||
|
closeOnOverlayClick: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: "取消",
|
||||||
|
onClick: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "确定",
|
||||||
|
onClick: async () => {
|
||||||
|
const ls = Object.keys(checkedList).filter((chatId) => checkedList[chatId] == true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
shared.myProfileCache!.removeFavouriteChatsOrThrow(ls)
|
||||||
|
|
||||||
|
setCheckedList({})
|
||||||
|
setIsMultiSelecting(false)
|
||||||
|
|
||||||
|
shared.functions_lazy.current.updateFavouriteChats()
|
||||||
|
|
||||||
|
showSnackbar({
|
||||||
|
message: "已删除所选",
|
||||||
|
action: "撤销操作",
|
||||||
|
onActionClick: async () => {
|
||||||
|
try {
|
||||||
|
shared.myProfileCache!.addFavouriteChatsOrThrow(ls)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '撤销删除收藏失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
shared.functions_lazy.current.updateFavouriteChats()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '删除收藏对话失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})}>删除所选</mdui-list-item>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<div style={{
|
||||||
|
height: "10px",
|
||||||
|
}}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
favouriteChatsList.filter((chat) =>
|
||||||
|
searchText == '' ||
|
||||||
|
chat.getTitle().includes(searchText) ||
|
||||||
|
chat.getId().includes(searchText)
|
||||||
|
).map((v) =>
|
||||||
|
<FavouriteChatsListItem
|
||||||
|
active={isMultiSelecting ? checkedList[v.getId()] == true : (isMobileUI() ? false : shared.currentSelectedChatId == v.getId())}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMultiSelecting)
|
||||||
|
setCheckedList({
|
||||||
|
...checkedList,
|
||||||
|
[v.getId()]: !checkedList[v.getId()],
|
||||||
|
})
|
||||||
|
else
|
||||||
|
openChatInfoDialog(v)
|
||||||
|
}}
|
||||||
|
key={v.getId()}
|
||||||
|
chat={v} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</mdui-list>
|
||||||
|
}
|
||||||
28
client/ui/main-page/FavouriteChatsListItem.tsx
Normal file
28
client/ui/main-page/FavouriteChatsListItem.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Chat } from "lingchair-client-protocol"
|
||||||
|
import Avatar from "../Avatar.tsx"
|
||||||
|
import React from 'react'
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
|
||||||
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
chat: Chat
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FavouriteChatsListItem({ chat, active, ...prop }: Args) {
|
||||||
|
const title = chat.getTitle()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-list-item active={active} ref={ref} rounded style={{
|
||||||
|
marginTop: '3px',
|
||||||
|
marginBottom: '3px',
|
||||||
|
width: '100%',
|
||||||
|
}} {...prop}>
|
||||||
|
<span style={{
|
||||||
|
width: "100%",
|
||||||
|
}}>{title}</span>
|
||||||
|
<Avatar src={getClient().getUrlForFileByHash(chat.getAvatarFileHash() as string)} text={title} slot="icon" />
|
||||||
|
</mdui-list-item>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
client/ui/main-page/LoginDialog.tsx
Normal file
49
client/ui/main-page/LoginDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Dialog, TextField } from "mdui"
|
||||||
|
|
||||||
|
import performAuth from '../../performAuth.ts'
|
||||||
|
import showSnackbar from '../../utils/showSnackbar.ts'
|
||||||
|
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
|
||||||
|
import { useContextSelector } from 'use-context-selector'
|
||||||
|
import useEventListener from '../../utils/useEventListener.ts'
|
||||||
|
|
||||||
|
export default function LoginDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||||
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
setShowRegisterDialog: context.setShowRegisterDialog,
|
||||||
|
setShowLoginDialog: context.setShowLoginDialog
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dialogRef = React.useRef<Dialog>()
|
||||||
|
useEventListener(dialogRef, 'closed', () => shared.setShowLoginDialog(false))
|
||||||
|
|
||||||
|
const loginInputAccountRef = React.useRef<TextField>(null)
|
||||||
|
const loginInputPasswordRef = React.useRef<TextField>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-dialog {...props} headline="登录" ref={dialogRef}>
|
||||||
|
|
||||||
|
<mdui-text-field label="用户 ID / 用户名" ref={loginInputAccountRef}></mdui-text-field>
|
||||||
|
<div style={{
|
||||||
|
height: "10px",
|
||||||
|
}}></div>
|
||||||
|
<mdui-text-field label="密码" type="password" toggle-password ref={loginInputPasswordRef}></mdui-text-field>
|
||||||
|
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => shared.setShowRegisterDialog(true)}>注册</mdui-button>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={async () => {
|
||||||
|
const account = loginInputAccountRef.current!.value
|
||||||
|
const password = loginInputPasswordRef.current!.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performAuth({
|
||||||
|
account: account,
|
||||||
|
password: password,
|
||||||
|
})
|
||||||
|
location.reload()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error)
|
||||||
|
showSnackbar({ message: '登录失败: ' + e.message })
|
||||||
|
}
|
||||||
|
}}>登录</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
client/ui/main-page/RecentChatsList.tsx
Normal file
83
client/ui/main-page/RecentChatsList.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { TextField } from "mdui"
|
||||||
|
import RecentsListItem from "./RecentsListItem.tsx"
|
||||||
|
import React from "react"
|
||||||
|
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
|
||||||
|
import { data } from "react-router"
|
||||||
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
import { CallbackError } from "lingchair-client-protocol"
|
||||||
|
import { useContextSelector } from "use-context-selector"
|
||||||
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
|
||||||
|
export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
myProfileCache: context.myProfileCache,
|
||||||
|
functions_lazy: context.functions_lazy,
|
||||||
|
currentSelectedChatId: context.currentSelectedChatId,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const searchRef = React.useRef<HTMLElement>(null)
|
||||||
|
const [searchText, setSearchText] = React.useState('')
|
||||||
|
const [recentsList, setRecentsList] = React.useState<RecentChat[]>([])
|
||||||
|
|
||||||
|
useEventListener(searchRef, 'input', (e) => {
|
||||||
|
setSearchText((e.target as unknown as TextField).value)
|
||||||
|
})
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
async function updateRecents() {
|
||||||
|
try {
|
||||||
|
setRecentsList(await shared.myProfileCache!.getMyRecentChats())
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
if (e.code != 401 && e.code != 400)
|
||||||
|
showSnackbar({
|
||||||
|
message: '获取最近对话失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateRecents()
|
||||||
|
|
||||||
|
shared.functions_lazy.current.updateRecentChats = updateRecents
|
||||||
|
|
||||||
|
const id = setInterval(() => updateRecents(), 15 * 1000)
|
||||||
|
return () => {
|
||||||
|
clearInterval(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <mdui-list style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: '10px',
|
||||||
|
paddingLeft: '10px',
|
||||||
|
paddingTop: '0',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
...props?.style,
|
||||||
|
}} {...props}>
|
||||||
|
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||||
|
paddingTop: '12px',
|
||||||
|
marginBottom: '13px',
|
||||||
|
position: 'sticky',
|
||||||
|
top: '0',
|
||||||
|
backgroundColor: 'rgb(var(--mdui-color-background))',
|
||||||
|
zIndex: '10',
|
||||||
|
}}></mdui-text-field>
|
||||||
|
{
|
||||||
|
recentsList.filter((chat) =>
|
||||||
|
searchText == '' ||
|
||||||
|
chat.getTitle().includes(searchText) ||
|
||||||
|
chat.getId().includes(searchText) ||
|
||||||
|
chat.getContent().includes(searchText)
|
||||||
|
).map((v) =>
|
||||||
|
<RecentsListItem
|
||||||
|
active={isMobileUI() ? false : shared.currentSelectedChatId == v.getId()}
|
||||||
|
openChatFragment={() => openChatFragment(v.getId())}
|
||||||
|
key={v.getId()}
|
||||||
|
recentChat={v} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</mdui-list>
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import { $ } from "mdui/jq"
|
import { $ } from "mdui/jq"
|
||||||
import RecentChat from "../../api/client_data/RecentChat.ts"
|
|
||||||
import Avatar from "../Avatar.tsx"
|
import Avatar from "../Avatar.tsx"
|
||||||
import React from 'react'
|
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> {
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
recentChat: RecentChat
|
recentChat: RecentChat
|
||||||
openChatFragment: (id: string) => void
|
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentsListItem({ recentChat, openChatFragment, active }: Args) {
|
export default function RecentsListItem({ recentChat, active, ...props }: Args) {
|
||||||
const { id, title, avatar_file_hash, content } = recentChat
|
const { id, title, avatar_file_hash, content } = recentChat.bean
|
||||||
|
|
||||||
const itemRef = React.useRef<HTMLElement>(null)
|
const itemRef = React.useRef<HTMLElement>(null)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -21,9 +20,9 @@ export default function RecentsListItem({ recentChat, openChatFragment, active }
|
|||||||
<mdui-list-item rounded style={{
|
<mdui-list-item rounded style={{
|
||||||
marginTop: '3px',
|
marginTop: '3px',
|
||||||
marginBottom: '3px',
|
marginBottom: '3px',
|
||||||
}} onClick={() => openChatFragment(id)} active={active} ref={itemRef}>
|
}} active={active} ref={itemRef} {...props}>
|
||||||
{title}
|
{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"
|
<span slot="description"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
76
client/ui/main-page/RegisterDialog.tsx
Normal file
76
client/ui/main-page/RegisterDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Button, Dialog, TextField } from "mdui"
|
||||||
|
import MainSharedContext, { Shared } from '../MainSharedContext'
|
||||||
|
import showSnackbar from '../../utils/showSnackbar'
|
||||||
|
import showCircleProgressDialog from '../showCircleProgressDialog'
|
||||||
|
import getClient from '../../getClient'
|
||||||
|
import performAuth from '../../performAuth'
|
||||||
|
import { useContextSelector } from 'use-context-selector'
|
||||||
|
import useEventListener from '../../utils/useEventListener'
|
||||||
|
|
||||||
|
export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||||
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
setShowRegisterDialog: context.setShowRegisterDialog
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dialogRef = React.useRef<Dialog>()
|
||||||
|
useEventListener(dialogRef, 'closed', () => shared.setShowRegisterDialog(false))
|
||||||
|
|
||||||
|
const registerInputUserNameRef = React.useRef<TextField>(null)
|
||||||
|
const registerInputNickNameRef = React.useRef<TextField>(null)
|
||||||
|
const registerInputPasswordRef = React.useRef<TextField>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-dialog headline="注册" {...props} ref={dialogRef}>
|
||||||
|
|
||||||
|
<mdui-text-field label="用户名 (可选)" ref={registerInputUserNameRef}></mdui-text-field>
|
||||||
|
<div style={{
|
||||||
|
height: "10px",
|
||||||
|
}}></div>
|
||||||
|
<mdui-text-field label="昵称" ref={registerInputNickNameRef}></mdui-text-field>
|
||||||
|
<div style={{
|
||||||
|
height: "10px",
|
||||||
|
}}></div>
|
||||||
|
<mdui-text-field label="密码" type="password" toggle-password ref={registerInputPasswordRef}></mdui-text-field>
|
||||||
|
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => shared.setShowRegisterDialog(false)}>返回</mdui-button>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={async () => {
|
||||||
|
const waitingForRegister = showCircleProgressDialog("注册中...")
|
||||||
|
|
||||||
|
const username = registerInputUserNameRef.current!.value
|
||||||
|
|
||||||
|
let user_id: string
|
||||||
|
try {
|
||||||
|
user_id = await getClient().registerOrThrow({
|
||||||
|
username: username,
|
||||||
|
nickname: registerInputNickNameRef.current!.value,
|
||||||
|
password: registerInputPasswordRef.current!.value,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
user_id = ''
|
||||||
|
if (e instanceof Error) {
|
||||||
|
waitingForRegister.open = false
|
||||||
|
showSnackbar({ message: '注册失败: ' + e.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitingForRegister.open = false
|
||||||
|
|
||||||
|
const waitingForLogin = showCircleProgressDialog("登录中...")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performAuth({
|
||||||
|
account: username == '' ? username : user_id,
|
||||||
|
password: registerInputPasswordRef.current!.value,
|
||||||
|
})
|
||||||
|
location.reload()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
showSnackbar({ message: '登录失败: ' + e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitingForLogin.open = false
|
||||||
|
}}>注册</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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"
|
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
|
||||||
display: boolean
|
|
||||||
currentChatId: string
|
|
||||||
openChatInfoDialog: (chat: Chat) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
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[]>([])
|
|
||||||
|
|
||||||
useEventListener(searchRef, 'input', (e) => {
|
|
||||||
setSearchText((e.target as unknown as TextField).value)
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
setAllChatsList(re.data!.all_chats as Chat[])
|
|
||||||
}
|
|
||||||
updateAllChats()
|
|
||||||
EventBus.on('AllChatsList.updateAllChats', () => updateAllChats())
|
|
||||||
return () => {
|
|
||||||
EventBus.off('AllChatsList.updateAllChats')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return <mdui-list style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '10px',
|
|
||||||
paddingLeft: '10px',
|
|
||||||
display: display ? undefined : 'none',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
}} {...props}>
|
|
||||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
|
||||||
marginTop: '5px',
|
|
||||||
marginBottom: '13px',
|
|
||||||
position: 'sticky',
|
|
||||||
top: '0',
|
|
||||||
backgroundColor: 'rgb(var(--mdui-color-background))',
|
|
||||||
zIndex: '10',
|
|
||||||
}}></mdui-text-field>
|
|
||||||
{
|
|
||||||
allChatsList.filter((chat) =>
|
|
||||||
searchText == '' ||
|
|
||||||
chat.title.includes(searchText) ||
|
|
||||||
chat.id.includes(searchText)
|
|
||||||
).map((v) =>
|
|
||||||
<AllChatsListItem
|
|
||||||
active={isMobileUI() ? false : currentChatId == v.id}
|
|
||||||
key={v.id}
|
|
||||||
onClick={() => {
|
|
||||||
openChatInfoDialog(v)
|
|
||||||
}}
|
|
||||||
chat={v} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</mdui-list>
|
|
||||||
}
|
|
||||||
@@ -1,170 +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',
|
|
||||||
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: "15px",
|
|
||||||
}}></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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,85 +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',
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export default function PreferenceHeader({ title }: {
|
|
||||||
title: string
|
|
||||||
}) {
|
|
||||||
return <mdui-list-subheader>{title}</mdui-list-subheader>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user