Compare commits
77 Commits
20986af1ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d2779922 | ||
|
|
a9dbb9655b | ||
|
|
2aa9425334 | ||
|
|
ec527bafc6 | ||
|
|
44ada8206d | ||
|
|
3044cabcaa | ||
|
|
7e6cbbdce4 | ||
|
|
9e3c1c554f | ||
|
|
01ece27e75 | ||
|
|
da505305a3 | ||
|
|
200f5fd0aa | ||
|
|
d35ce7a255 | ||
|
|
326d62a8bd | ||
|
|
25e5650441 | ||
|
|
263a02e0c7 | ||
|
|
687088a284 | ||
|
|
922791a0f5 | ||
|
|
6bfa1bf6b7 | ||
|
|
6aa985bfca | ||
|
|
7db95ed677 | ||
|
|
82de2eff42 | ||
|
|
3bada7c431 | ||
|
|
d4557ca0ae | ||
|
|
bc603b8171 | ||
|
|
512419c131 | ||
|
|
ba97ea359a | ||
|
|
722b06c018 | ||
|
|
4e57a5f9e9 | ||
|
|
72ca6a2fca | ||
|
|
6e0a89f861 | ||
|
|
c423117ad5 | ||
|
|
3514f87699 | ||
|
|
cf560909e7 | ||
|
|
940845db84 | ||
|
|
01c1ccfd00 | ||
|
|
d944401691 | ||
|
|
d4d28c2760 | ||
|
|
aa8a205e5f | ||
|
|
ef84cc30c0 | ||
|
|
f36c747a72 | ||
|
|
44168b4704 | ||
|
|
12039612ca | ||
|
|
1e2e90f9e7 | ||
|
|
2ad2e6e863 | ||
|
|
3b3e9a3d9d | ||
|
|
5418d492ae | ||
|
|
989933d07c | ||
|
|
76d518f229 | ||
|
|
8c74eaacb1 | ||
|
|
db3dca724a | ||
|
|
56f651f084 | ||
|
|
6a1ae692f9 | ||
|
|
8fad24ecb4 | ||
|
|
19ed8c0357 | ||
|
|
a66d137773 | ||
|
|
484a5efb99 | ||
|
|
75dfced90f | ||
|
|
668e84e102 | ||
|
|
505e629f30 | ||
|
|
895ea6e4e1 | ||
|
|
856aeb868a | ||
|
|
ae0e7fee95 | ||
|
|
8fbf84d5dc | ||
|
|
6ea8d6979f | ||
|
|
3351d7dc4e | ||
|
|
dee8a24f0b | ||
|
|
3a7fe53038 | ||
|
|
16bacea5e3 | ||
|
|
22bf643d5e | ||
|
|
bb065f1ecc | ||
|
|
e22d99d04f | ||
|
|
a6ddb9025a | ||
|
|
bf1551c6c3 | ||
|
|
28215fc1f8 | ||
|
|
de028556af | ||
|
|
1a8df07c3b | ||
|
|
991a8a729d |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mdui_patched/** linguist-vendored -diff
|
||||||
@@ -53,7 +53,7 @@ export default class Chat extends BaseClientObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
static async getOrCreatePrivateChatOrThrow(client: LingChairClient, user_id: string) {
|
static async getOrCreatePrivateChatOrThrow(client: LingChairClient, user_id: string) {
|
||||||
const re = await client.invoke("Chat.getIdForPrivate", {
|
const re = await client.invoke("Chat.getOrCreatePrivateChat", {
|
||||||
token: client.access_token,
|
token: client.access_token,
|
||||||
target: user_id,
|
target: user_id,
|
||||||
})
|
})
|
||||||
@@ -81,23 +81,25 @@ export default class Chat extends BaseClientObject {
|
|||||||
* 对话消息
|
* 对话消息
|
||||||
* ================================================
|
* ================================================
|
||||||
*/
|
*/
|
||||||
async getMessages(page: number = 0) {
|
async getMessages(args: { page?: number, offset?: number, limit?: number }) {
|
||||||
return (await this.getMessageBeans(page)).map((v) => new Message(this.client, v))
|
return (await this.getMessageBeans(args)).map((v) => new Message(this.client, v))
|
||||||
}
|
}
|
||||||
async getMessagesOrThrow(page: number = 0) {
|
async getMessagesOrThrow(args: { page?: number, offset?: number, limit?: number }) {
|
||||||
return (await this.getMessageBeansOrThrow(page)).map((v) => new Message(this.client, v))
|
return (await this.getMessageBeansOrThrow(args)).map((v) => new Message(this.client, v))
|
||||||
}
|
}
|
||||||
async getMessageBeans(page: number = 0) {
|
async getMessageBeans(args: { page?: number, offset?: number, limit?: number }) {
|
||||||
try {
|
try {
|
||||||
return await this.getMessageBeansOrThrow(page)
|
return await this.getMessageBeansOrThrow(args)
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getMessageBeansOrThrow(page: number = 0) {
|
async getMessageBeansOrThrow({ page, offset, limit }: { page?: number, offset?: number, limit?: number }) {
|
||||||
const re = await this.client.invoke("Chat.getMessageHistory", {
|
const re = await this.client.invoke("Chat.getMessageHistory", {
|
||||||
token: this.client.access_token,
|
token: this.client.access_token,
|
||||||
page,
|
page,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
target: this.bean.id,
|
target: this.bean.id,
|
||||||
})
|
})
|
||||||
if (re.code == 200) return re.data!.messages as MessageBean[]
|
if (re.code == 200) return re.data!.messages as MessageBean[]
|
||||||
@@ -198,7 +200,7 @@ export default class Chat extends BaseClientObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getTheOtherUserIdOrThrow() {
|
async getTheOtherUserIdOrThrow() {
|
||||||
const re = await this.client.invoke("Chat.updateSettings", {
|
const re = await this.client.invoke("Chat.getAnotherUserIdFromPrivate", {
|
||||||
token: this.client.access_token,
|
token: this.client.access_token,
|
||||||
target: this.bean.id,
|
target: this.bean.id,
|
||||||
})
|
})
|
||||||
|
|||||||
90
client-protocol/ChatAttachment.ts
Normal file
90
client-protocol/ChatAttachment.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { ApiCallbackMessage } from 'lingchair-internal-shared'
|
||||||
|
import BaseClientObject from './BaseClientObject.ts'
|
||||||
|
import CallbackError from './CallbackError.ts'
|
||||||
|
import LingChairClient from './LingChairClient.ts'
|
||||||
|
|
||||||
|
export default class ChatAttachment extends BaseClientObject {
|
||||||
|
declare file_hash: string
|
||||||
|
declare file_name: string
|
||||||
|
constructor(client: LingChairClient, {
|
||||||
|
file_hash,
|
||||||
|
file_name
|
||||||
|
}: {
|
||||||
|
file_hash: string,
|
||||||
|
file_name: string
|
||||||
|
}) {
|
||||||
|
super(client)
|
||||||
|
this.file_name = file_name
|
||||||
|
this.file_hash = file_hash
|
||||||
|
}
|
||||||
|
async blob() {
|
||||||
|
try {
|
||||||
|
return await this.blobOrThrow()
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetch(init?: RequestInit) {
|
||||||
|
const url = this.client.getUrlForFileByHash(this.file_hash)
|
||||||
|
return fetch(url!, init)
|
||||||
|
}
|
||||||
|
async blobOrThrow() {
|
||||||
|
const re = await this.fetch()
|
||||||
|
const blob = await re.blob()
|
||||||
|
if (!re.ok) throw new CallbackError({
|
||||||
|
msg: await blob.text(),
|
||||||
|
code: re.status,
|
||||||
|
} as ApiCallbackMessage)
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
async getMimeType() {
|
||||||
|
try {
|
||||||
|
return await this.getMimeTypeOrThrow()
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getMimeTypeOrThrow() {
|
||||||
|
const re = await this.fetch({
|
||||||
|
method: 'HEAD'
|
||||||
|
})
|
||||||
|
if (re.ok) {
|
||||||
|
const t = re.headers.get('content-type')
|
||||||
|
if (t)
|
||||||
|
return t
|
||||||
|
throw new Error("Unable to get Content-Type")
|
||||||
|
}
|
||||||
|
throw new CallbackError({
|
||||||
|
msg: await re.text(),
|
||||||
|
code: re.status,
|
||||||
|
} as ApiCallbackMessage)
|
||||||
|
}
|
||||||
|
async getLength() {
|
||||||
|
try {
|
||||||
|
return await this.getLengthOrThrow()
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getLengthOrThrow() {
|
||||||
|
const re = await this.fetch({
|
||||||
|
method: 'HEAD'
|
||||||
|
})
|
||||||
|
if (re.ok) {
|
||||||
|
const contentLength = re.headers.get('content-length')
|
||||||
|
if (contentLength)
|
||||||
|
return parseInt(contentLength)
|
||||||
|
throw new Error("Unable to get Content-Length")
|
||||||
|
}
|
||||||
|
throw new CallbackError({
|
||||||
|
msg: await re.text(),
|
||||||
|
code: re.status,
|
||||||
|
} as ApiCallbackMessage)
|
||||||
|
}
|
||||||
|
getFileHash() {
|
||||||
|
return this.file_hash
|
||||||
|
}
|
||||||
|
getFileName() {
|
||||||
|
return this.file_name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,7 +186,7 @@ export default class LingChairClient {
|
|||||||
throw new CallbackError(re)
|
throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
getBaseHttpUrl() {
|
getBaseHttpUrl() {
|
||||||
const url = new URL(this.server_url)
|
const url = new URL(this.client.io.opts.host || (this.server_url == '' ? `${window.location.protocol}//${window.location.host}` : this.server_url))
|
||||||
return (({
|
return (({
|
||||||
'ws:': 'http:',
|
'ws:': 'http:',
|
||||||
'wss:': 'https:',
|
'wss:': 'https:',
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import MessageBean from "./bean/MessageBean.ts"
|
|||||||
import LingChairClient from "./LingChairClient.ts"
|
import LingChairClient from "./LingChairClient.ts"
|
||||||
import Chat from "./Chat.ts"
|
import Chat from "./Chat.ts"
|
||||||
import User from "./User.ts"
|
import User from "./User.ts"
|
||||||
import CallbackError from "./CallbackError.ts"
|
import ChatAttachment from "./ChatAttachment.ts"
|
||||||
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
|
|
||||||
|
|
||||||
import * as marked from 'marked'
|
import * as marked from 'marked'
|
||||||
import { text } from "node:stream/consumers";
|
|
||||||
|
|
||||||
class ChatMention extends BaseClientObject {
|
export class ChatMention extends BaseClientObject {
|
||||||
declare chat_id?: string
|
declare chat_id?: string
|
||||||
declare user_id?: string
|
declare user_id?: string
|
||||||
declare text?: string
|
declare text?: string
|
||||||
@@ -38,93 +36,18 @@ class ChatMention extends BaseClientObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileType = 'Video' | 'Image' | 'File'
|
type ChatMentionType = 'ChatMention' | 'UserMention'
|
||||||
type MentionType = 'ChatMention' | 'UserMention'
|
type ChatFileType = 'Video' | 'Image' | 'File'
|
||||||
|
|
||||||
class ChatAttachment extends BaseClientObject {
|
type ChatParserTransformers = {
|
||||||
declare file_hash: string
|
attachment?: ({ text, fileType, attachment }: { text: string, fileType: ChatFileType, attachment: ChatAttachment }) => string,
|
||||||
declare file_name: string
|
mention?: ({ text, mentionType, mention }: { text: string, mentionType: ChatMentionType, mention: ChatMention }) => string,
|
||||||
constructor(client: LingChairClient, {
|
}
|
||||||
file_hash,
|
|
||||||
file_name
|
export type {
|
||||||
}: {
|
ChatMentionType,
|
||||||
file_hash: string,
|
ChatFileType,
|
||||||
file_name: string
|
ChatParserTransformers,
|
||||||
}) {
|
|
||||||
super(client)
|
|
||||||
this.file_name = file_name
|
|
||||||
this.file_hash = file_hash
|
|
||||||
}
|
|
||||||
async blob() {
|
|
||||||
try {
|
|
||||||
return await this.blobOrThrow()
|
|
||||||
} catch (_) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetch(init?: RequestInit) {
|
|
||||||
const url = this.client.getUrlForFileByHash(this.file_hash)
|
|
||||||
return fetch(url!, init)
|
|
||||||
}
|
|
||||||
async blobOrThrow() {
|
|
||||||
const re = await this.fetch()
|
|
||||||
const blob = await re.blob()
|
|
||||||
if (!re.ok) throw new CallbackError({
|
|
||||||
msg: await blob.text(),
|
|
||||||
code: re.status,
|
|
||||||
} as ApiCallbackMessage)
|
|
||||||
return blob
|
|
||||||
}
|
|
||||||
async getMimeType() {
|
|
||||||
try {
|
|
||||||
return await this.getMimeTypeOrThrow()
|
|
||||||
} catch (_) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async getMimeTypeOrThrow() {
|
|
||||||
const re = await this.fetch({
|
|
||||||
method: 'HEAD'
|
|
||||||
})
|
|
||||||
if (re.ok) {
|
|
||||||
const t = re.headers.get('content-type')
|
|
||||||
if (t)
|
|
||||||
return t
|
|
||||||
throw new Error("Unable to get Content-Type")
|
|
||||||
}
|
|
||||||
throw new CallbackError({
|
|
||||||
msg: await re.text(),
|
|
||||||
code: re.status,
|
|
||||||
} as ApiCallbackMessage)
|
|
||||||
}
|
|
||||||
async getLength() {
|
|
||||||
try {
|
|
||||||
return await this.getLengthOrThrow()
|
|
||||||
} catch (_) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async getLengthOrThrow() {
|
|
||||||
const re = await this.fetch({
|
|
||||||
method: 'HEAD'
|
|
||||||
})
|
|
||||||
if (re.ok) {
|
|
||||||
const contentLength = re.headers.get('content-length')
|
|
||||||
if (contentLength)
|
|
||||||
return parseInt(contentLength)
|
|
||||||
throw new Error("Unable to get Content-Length")
|
|
||||||
}
|
|
||||||
throw new CallbackError({
|
|
||||||
msg: await re.text(),
|
|
||||||
code: re.status,
|
|
||||||
} as ApiCallbackMessage)
|
|
||||||
}
|
|
||||||
getFileHash() {
|
|
||||||
return this.file_hash
|
|
||||||
}
|
|
||||||
getFileName() {
|
|
||||||
return this.file_name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Message extends BaseClientObject {
|
export default class Message extends BaseClientObject {
|
||||||
@@ -153,10 +76,7 @@ export default class Message extends BaseClientObject {
|
|||||||
parseWithTransformers({
|
parseWithTransformers({
|
||||||
attachment,
|
attachment,
|
||||||
mention,
|
mention,
|
||||||
}: {
|
}: ChatParserTransformers) {
|
||||||
attachment?: ({ text, fileType, attachment }: { text: string, fileType: FileType, attachment: ChatAttachment }) => string,
|
|
||||||
mention?: ({ text, mentionType, mention }: { text: string, mentionType: MentionType, mention: ChatMention }) => string,
|
|
||||||
}) {
|
|
||||||
return new marked.Marked({
|
return new marked.Marked({
|
||||||
async: false,
|
async: false,
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -179,8 +99,8 @@ export default class Message extends BaseClientObject {
|
|||||||
{
|
{
|
||||||
name: 'image',
|
name: 'image',
|
||||||
renderer: ({ text, href }) => {
|
renderer: ({ text, href }) => {
|
||||||
const mentionType = /^(UserMention|ChatMention)=.*/.exec(text)?.[1] as MentionType
|
const mentionType = /^(UserMention|ChatMention)=.*/.exec(text)?.[1] as ChatMentionType
|
||||||
const fileType = (/^(Video|File|Image)=.*/.exec(text)?.[1] || 'Image') as FileType
|
const fileType = (/^(Video|File|Image)=.*/.exec(text)?.[1] || 'Image') as ChatFileType
|
||||||
|
|
||||||
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]!
|
||||||
|
|||||||
@@ -123,9 +123,9 @@ export default class UserMySelf extends User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async addFavouriteChatsOrThrow(chat_ids: string[]) {
|
async addFavouriteChatsOrThrow(chat_ids: string[]) {
|
||||||
const re = await this.client.invoke("User.addContacts", {
|
const re = await this.client.invoke("User.addFavouriteChats", {
|
||||||
token: this.client.access_token,
|
token: this.client.access_token,
|
||||||
targets: chat_ids,
|
chat_ids,
|
||||||
})
|
})
|
||||||
if (re.code != 200) throw new CallbackError(re)
|
if (re.code != 200) throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
@@ -138,9 +138,9 @@ export default class UserMySelf extends User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async removeFavouriteChatsOrThrow(chat_ids: string[]) {
|
async removeFavouriteChatsOrThrow(chat_ids: string[]) {
|
||||||
const re = await this.client.invoke("User.removeContacts", {
|
const re = await this.client.invoke("User.removeFavouriteChats", {
|
||||||
token: this.client.access_token,
|
token: this.client.access_token,
|
||||||
targets: chat_ids,
|
chat_ids,
|
||||||
})
|
})
|
||||||
if (re.code != 200) throw new CallbackError(re)
|
if (re.code != 200) throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
@@ -152,11 +152,11 @@ export default class UserMySelf extends User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getMyFavouriteChatBeansOrThrow() {
|
async getMyFavouriteChatBeansOrThrow() {
|
||||||
const re = await this.client.invoke("User.getMyContacts", {
|
const re = await this.client.invoke("User.getMyFavouriteChats", {
|
||||||
token: this.client.access_token
|
token: this.client.access_token
|
||||||
})
|
})
|
||||||
if (re.code == 200)
|
if (re.code == 200)
|
||||||
return (re.data!.favourite_chats || re.data!.contacts_list) as ChatBean[]
|
return (re.data!.favourite_chats) as ChatBean[]
|
||||||
throw new CallbackError(re)
|
throw new CallbackError(re)
|
||||||
}
|
}
|
||||||
async getMyFavouriteChats() {
|
async getMyFavouriteChats() {
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import GroupSettingsBean from "./bean/GroupSettingsBean.ts"
|
|||||||
import JoinRequestBean from "./bean/JoinRequestBean.ts"
|
import JoinRequestBean from "./bean/JoinRequestBean.ts"
|
||||||
import MessageBean from "./bean/MessageBean.ts"
|
import MessageBean from "./bean/MessageBean.ts"
|
||||||
import RecentChatBean from "./bean/RecentChatBean.ts"
|
import RecentChatBean from "./bean/RecentChatBean.ts"
|
||||||
|
import Message, {
|
||||||
|
ChatMention,
|
||||||
|
ChatParserTransformers,
|
||||||
|
ChatMentionType,
|
||||||
|
ChatFileType,
|
||||||
|
} from "./Message.ts"
|
||||||
|
|
||||||
import LingChairClient from "./LingChairClient.ts"
|
import LingChairClient from "./LingChairClient.ts"
|
||||||
import CallbackError from "./CallbackError.ts"
|
import CallbackError from "./CallbackError.ts"
|
||||||
|
import ChatAttachment from "./ChatAttachment.ts"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LingChairClient,
|
LingChairClient,
|
||||||
@@ -19,10 +26,20 @@ export {
|
|||||||
User,
|
User,
|
||||||
UserMySelf,
|
UserMySelf,
|
||||||
|
|
||||||
|
Message,
|
||||||
|
ChatAttachment,
|
||||||
|
ChatMention,
|
||||||
|
|
||||||
UserBean,
|
UserBean,
|
||||||
ChatBean,
|
ChatBean,
|
||||||
MessageBean,
|
MessageBean,
|
||||||
RecentChatBean,
|
RecentChatBean,
|
||||||
JoinRequestBean,
|
JoinRequestBean,
|
||||||
}
|
}
|
||||||
export type { GroupSettingsBean }
|
export type {
|
||||||
|
ChatParserTransformers,
|
||||||
|
ChatMentionType,
|
||||||
|
ChatFileType,
|
||||||
|
|
||||||
|
GroupSettingsBean,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lingchair-client-protocol",
|
"name": "lingchair-client-protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./main.ts",
|
"main": "./main.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
5
client-protocol/type/ChatParserTransformers.ts
Normal file
5
client-protocol/type/ChatParserTransformers.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import ChatAttachment from '../ChatAttachment.ts'
|
||||||
|
import { ChatMention } from '../Message.ts'
|
||||||
|
import ChatFileType from './ChatFileType.ts'
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Chat, User } from "lingchair-client-protocol"
|
import { Chat, User, UserMySelf } from "lingchair-client-protocol"
|
||||||
import getClient from "./getClient"
|
import getClient from "./getClient.ts"
|
||||||
|
|
||||||
type CouldCached = User | Chat | null
|
type CouldCached = User | Chat | null
|
||||||
export default class ClientCache {
|
export default class ClientCache {
|
||||||
static caches: { [key: string]: CouldCached } = {}
|
static caches: { [key: string]: CouldCached } = {}
|
||||||
|
|
||||||
|
static async getMySelf() {
|
||||||
|
const k = 'usermyself'
|
||||||
|
if (this.caches[k] != null)
|
||||||
|
return this.caches[k] as UserMySelf | null
|
||||||
|
this.caches[k] = await UserMySelf.getMySelf(getClient())
|
||||||
|
return this.caches[k] as UserMySelf | null
|
||||||
|
}
|
||||||
|
|
||||||
static async getUser(id: string) {
|
static async getUser(id: string) {
|
||||||
const k = 'user_' + id
|
const k = 'user_' + id
|
||||||
if (this.caches[k] != null)
|
if (this.caches[k] != null)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type IData = {
|
|||||||
apply(): void
|
apply(): void
|
||||||
access_token?: string
|
access_token?: string
|
||||||
device_id: string
|
device_id: string
|
||||||
|
override_use_mobile_ui?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
14
client/env.d.ts
vendored
14
client/env.d.ts
vendored
@@ -1,6 +1,20 @@
|
|||||||
/// <reference types="mdui/jsx.zh-cn.d.ts" />
|
/// <reference types="mdui/jsx.zh-cn.d.ts" />
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// 貌似没有起效
|
||||||
|
declare global {
|
||||||
|
namespace React {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'mdui-patched-textarea': {
|
||||||
|
'value'?: string
|
||||||
|
insertHtml: (html: string) => void
|
||||||
|
} & React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string
|
||||||
declare const __GIT_HASH__: string
|
declare const __GIT_HASH__: string
|
||||||
declare const __GIT_HASH_FULL__: string
|
declare const __GIT_HASH_FULL__: string
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { LingChairClient } from 'lingchair-client-protocol'
|
|||||||
import data from "./data.ts"
|
import data from "./data.ts"
|
||||||
import { UAParser } from 'ua-parser-js'
|
import { UAParser } from 'ua-parser-js'
|
||||||
import { randomUUID } from 'lingchair-internal-shared'
|
import { randomUUID } from 'lingchair-internal-shared'
|
||||||
import performAuth from './performAuth.ts'
|
|
||||||
|
|
||||||
if (!data.device_id) {
|
if (!data.device_id) {
|
||||||
const ua = new UAParser(navigator.userAgent)
|
const ua = new UAParser(navigator.userAgent)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app" style="display: flex; width: 100%;"></div>
|
||||||
|
|
||||||
<script type="module" src="./init.ts"></script>
|
<script type="module" src="./init.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import './ui/chat-elements/chat-text.ts'
|
|||||||
import './ui/chat-elements/chat-mention.ts'
|
import './ui/chat-elements/chat-mention.ts'
|
||||||
import './ui/chat-elements/chat-text-container.ts'
|
import './ui/chat-elements/chat-text-container.ts'
|
||||||
import './ui/chat-elements/chat-quote.ts'
|
import './ui/chat-elements/chat-quote.ts'
|
||||||
|
import './ui/MduiPatchedTextAreaElement.ts'
|
||||||
|
import './ui/InnerTextContainerElement.ts'
|
||||||
import Main from "./ui/Main.tsx"
|
import Main from "./ui/Main.tsx"
|
||||||
|
|
||||||
import performAuth from './performAuth.ts'
|
import performAuth from './performAuth.ts'
|
||||||
@@ -36,6 +38,3 @@ const onResize = () => {
|
|||||||
// deno-lint-ignore no-window no-window-prefix
|
// deno-lint-ignore no-window no-window-prefix
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
onResize()
|
onResize()
|
||||||
|
|
||||||
const config = await fetch('config.json').then((re) => re.json())
|
|
||||||
config.title && (document.title = config.title)
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lingchair-client",
|
"name": "lingchair-client",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.0-alpha",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npx vite build",
|
"build": "npx vite build",
|
||||||
"build-watch": "npx vite --watch build"
|
"build-watch": "npx vite --watch build"
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"pinch-zoom-element": "1.1.1",
|
"pinch-zoom-element": "1.1.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.10.1",
|
|
||||||
"socket.io-client": "4.8.1",
|
"socket.io-client": "4.8.1",
|
||||||
"split.js": "1.3.2",
|
"split.js": "1.3.2",
|
||||||
"ua-parser-js": "2.0.6",
|
"ua-parser-js": "2.0.6",
|
||||||
@@ -24,6 +23,7 @@
|
|||||||
"@rollup/wasm-node": "4.48.0",
|
"@rollup/wasm-node": "4.48.0",
|
||||||
"@types/react": "18.3.1",
|
"@types/react": "18.3.1",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
|
"@types/split.js": "^1.4.0",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
"vite": "7.2.6",
|
"vite": "7.2.6",
|
||||||
|
|||||||
6
client/ui/EffectOnly.tsx
Normal file
6
client/ui/EffectOnly.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export default function EffectOnly({ effect, deps }: { effect: React.EffectCallback, deps?: React.DependencyList }) {
|
||||||
|
React.useEffect(effect, deps)
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -2,18 +2,23 @@ import { Dialog } from 'mdui'
|
|||||||
import 'pinch-zoom-element'
|
import 'pinch-zoom-element'
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
export default function ImageViewer() {
|
export default function ImageViewer({ ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
const dialogRef = React.useRef<Dialog>()
|
const dialogRef = React.useRef<Dialog>()
|
||||||
|
|
||||||
return <mdui-dialog ref={dialogRef} fullscreen="fullscreen">
|
return <mdui-dialog ref={dialogRef} fullscreen="fullscreen">
|
||||||
<mdui-button-icon icon="open_in_new"
|
<mdui-button-icon icon="open_in_new" onClick={() => window.open(props.src, '_blank')}>
|
||||||
onclick="window.open(document.querySelector('#image-viewer-dialog-inner > *').src, '_blank')">
|
|
||||||
</mdui-button-icon>
|
</mdui-button-icon>
|
||||||
<mdui-button-icon icon="close" onClick={() => dialogRef.current!.open = false}>
|
<mdui-button-icon icon="close" onClick={() => dialogRef.current!.open = false}>
|
||||||
</mdui-button-icon>
|
</mdui-button-icon>
|
||||||
{
|
{
|
||||||
// @ts-ignore 注册了这个元素
|
// @ts-ignore 注册了这个元素
|
||||||
<pinch-zoom id="image-viewer-dialog-inner" style="width: var(--whitesilk-window-width); height: var(--whitesilk-window-height);"></pinch-zoom>
|
<pinch-zoom id="image-viewer-dialog-inner" style={{
|
||||||
|
width: 'var(--whitesilk-window-width)',
|
||||||
|
height: 'var(--whitesilk-window-height)',
|
||||||
|
}}>
|
||||||
|
<img {...props}></img>
|
||||||
|
{/* @ts-ignore 注册了这个元素 */}
|
||||||
|
</pinch-zoom>
|
||||||
}
|
}
|
||||||
</mdui-dialog>
|
</mdui-dialog>
|
||||||
}
|
}
|
||||||
|
|||||||
24
client/ui/InnerTextContainerElement.ts
Normal file
24
client/ui/InnerTextContainerElement.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export default class InnerTextContainerElement extends HTMLElement {
|
||||||
|
static observedAttributes = ['text']
|
||||||
|
declare textContainer: HTMLDivElement
|
||||||
|
declare slotContainer: HTMLSlotElement
|
||||||
|
declare text?: string
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
}
|
||||||
|
connectedCallback() {
|
||||||
|
this.shadowRoot!.appendChild(document.createElement('slot'))
|
||||||
|
}
|
||||||
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
||||||
|
if (this.textContainer == null) {
|
||||||
|
this.textContainer = document.createElement('div')
|
||||||
|
// 注意这里不能加到 shadow
|
||||||
|
this.appendChild(this.textContainer)
|
||||||
|
this.textContainer.style.display = 'none'
|
||||||
|
}
|
||||||
|
this.textContainer.innerText = newValue || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('inner-text-container', InnerTextContainerElement)
|
||||||
@@ -3,7 +3,6 @@ import useEventListener from "../utils/useEventListener.ts"
|
|||||||
import AvatarMySelf from "./AvatarMySelf.tsx"
|
import AvatarMySelf from "./AvatarMySelf.tsx"
|
||||||
import MainSharedContext from './MainSharedContext.ts'
|
import MainSharedContext from './MainSharedContext.ts'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { BrowserRouter, Link, Outlet, Route, Routes } from "react-router"
|
|
||||||
import LoginDialog from "./main-page/LoginDialog.tsx"
|
import LoginDialog from "./main-page/LoginDialog.tsx"
|
||||||
import useAsyncEffect from "../utils/useAsyncEffect.ts"
|
import useAsyncEffect from "../utils/useAsyncEffect.ts"
|
||||||
import performAuth from "../performAuth.ts"
|
import performAuth from "../performAuth.ts"
|
||||||
@@ -16,11 +15,15 @@ import getClient from "../getClient.ts"
|
|||||||
import showSnackbar from "../utils/showSnackbar.ts"
|
import showSnackbar from "../utils/showSnackbar.ts"
|
||||||
import AllChatsList from "./main-page/AllChatsList.tsx"
|
import AllChatsList from "./main-page/AllChatsList.tsx"
|
||||||
import FavouriteChatsList from "./main-page/FavouriteChatsList.tsx"
|
import FavouriteChatsList from "./main-page/FavouriteChatsList.tsx"
|
||||||
import AddFavourtieChatDialog from "./main-page/AddFavourtieChatDialog.tsx"
|
|
||||||
import RecentChatsList from "./main-page/RecentChatsList.tsx"
|
import RecentChatsList from "./main-page/RecentChatsList.tsx"
|
||||||
import ChatInfoDialog from "./routers/ChatInfoDialog.tsx"
|
import MainSharedReducer from "./MainSharedReducer.ts"
|
||||||
|
import Split from 'split.js'
|
||||||
|
import data from "../data.ts"
|
||||||
|
import LazyChatFragment from "./chat-fragment/LazyChatFragment.tsx"
|
||||||
|
import DialogContextWrapper from "./app-state/AppStateContextWrapper.tsx"
|
||||||
|
import { AppState } from "./app-state/AppStateContext.ts"
|
||||||
|
|
||||||
export default function Main() {
|
function Root() {
|
||||||
const [myProfileCache, setMyProfileCache] = React.useState<UserMySelf>()
|
const [myProfileCache, setMyProfileCache] = React.useState<UserMySelf>()
|
||||||
|
|
||||||
// 多页面切换
|
// 多页面切换
|
||||||
@@ -46,11 +49,11 @@ export default function Main() {
|
|||||||
|
|
||||||
const [showLoginDialog, setShowLoginDialog] = React.useState(false)
|
const [showLoginDialog, setShowLoginDialog] = React.useState(false)
|
||||||
const [showRegisterDialog, setShowRegisterDialog] = React.useState(false)
|
const [showRegisterDialog, setShowRegisterDialog] = React.useState(false)
|
||||||
const [showAddFavourtieChatDialog, setShowAddFavourtieChatDialog] = React.useState(false)
|
|
||||||
|
|
||||||
const [currentSelectedChatId, setCurrentSelectedChatId] = React.useState('')
|
const [state, dispatch] = React.useReducer(MainSharedReducer, {
|
||||||
|
favouriteChats: [],
|
||||||
const [favouriteChats, setFavouriteChats] = React.useState<Chat[]>([])
|
currentSelectedChatId: '',
|
||||||
|
})
|
||||||
|
|
||||||
const sharedContext = {
|
const sharedContext = {
|
||||||
functions_lazy: React.useRef({
|
functions_lazy: React.useRef({
|
||||||
@@ -58,18 +61,13 @@ export default function Main() {
|
|||||||
updateRecentChats: () => { },
|
updateRecentChats: () => { },
|
||||||
updateAllChats: () => { },
|
updateAllChats: () => { },
|
||||||
}),
|
}),
|
||||||
favouriteChats,
|
state,
|
||||||
setFavouriteChats,
|
setFavouriteChats: (chats: Chat[]) => dispatch({ type: 'update_favourite_chat', data: chats }),
|
||||||
|
|
||||||
setShowLoginDialog,
|
setShowLoginDialog,
|
||||||
setShowRegisterDialog,
|
setShowRegisterDialog,
|
||||||
setShowAddFavourtieChatDialog,
|
|
||||||
|
|
||||||
currentSelectedChatId,
|
|
||||||
setCurrentSelectedChatId,
|
|
||||||
|
|
||||||
myProfileCache,
|
|
||||||
|
|
||||||
|
setCurrentSelectedChatId: (id: string) => dispatch({ type: 'update_selected_chat_id', data: id }),
|
||||||
}
|
}
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
@@ -95,37 +93,39 @@ export default function Main() {
|
|||||||
waitingForAuth.open = false
|
waitingForAuth.open = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const subRoutes = <>
|
React.useEffect(() => {
|
||||||
<Route path="/info">
|
if (!isMobileUI()) {
|
||||||
<Route path="chat" element={<ChatInfoDialog />} />
|
const split = Split(['#SideBar', '#ChatFragment'], {
|
||||||
<Route path="user" element={<ChatInfoDialog />} />
|
sizes: data.split_sizes ? data.split_sizes : [25, 75],
|
||||||
</Route>
|
minSize: [200, 400],
|
||||||
</>
|
gutterSize: 2,
|
||||||
|
onDragEnd: function () {
|
||||||
|
data.split_sizes = split.getSizes()
|
||||||
|
data.apply()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const AppStateRef = React.useRef<AppState>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainSharedContext.Provider value={sharedContext}>
|
<MainSharedContext.Provider value={sharedContext}>
|
||||||
<BrowserRouter>
|
<DialogContextWrapper useRef={AppStateRef}>
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={(
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
flexDirection: isMobileUI() ? 'column' : 'row',
|
flexDirection: isMobileUI() ? 'column' : 'row',
|
||||||
width: `calc(var(--whitesilk-window-width))${isMobileUI() ? '' : ' - 80px'}`,
|
width: '100%',
|
||||||
height: 'var(--whitesilk-window-height)',
|
height: 'var(--whitesilk-window-height)',
|
||||||
}}>
|
}}>
|
||||||
{
|
|
||||||
// 将子路由渲染到此处
|
|
||||||
<Outlet />
|
|
||||||
}
|
|
||||||
<LoginDialog open={showLoginDialog} />
|
<LoginDialog open={showLoginDialog} />
|
||||||
<RegisterDialog open={showRegisterDialog} />
|
<RegisterDialog open={showRegisterDialog} />
|
||||||
<AddFavourtieChatDialog open={showAddFavourtieChatDialog} />
|
|
||||||
<mdui-navigation-drawer ref={drawerRef} modal close-on-esc close-on-overlay-click>
|
<mdui-navigation-drawer ref={drawerRef} modal close-on-esc close-on-overlay-click>
|
||||||
<mdui-list style={{
|
<mdui-list style={{
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
}}>
|
}}>
|
||||||
<mdui-list-item rounded>
|
<mdui-list-item rounded onClick={() => AppStateRef.current!.openUserInfo(myProfileCache!.getId())}>
|
||||||
<span>{myProfileCache?.getNickName()}</span>
|
<span>{myProfileCache?.getNickName()}</span>
|
||||||
<AvatarMySelf slot="icon" />
|
<AvatarMySelf slot="icon" />
|
||||||
</mdui-list-item>
|
</mdui-list-item>
|
||||||
@@ -133,10 +133,9 @@ export default function Main() {
|
|||||||
<mdui-divider style={{
|
<mdui-divider style={{
|
||||||
margin: '10px',
|
margin: '10px',
|
||||||
}}></mdui-divider>
|
}}></mdui-divider>
|
||||||
<mdui-list-item rounded icon="person_add">添加收藏对话</mdui-list-item>
|
<mdui-list-item rounded icon="settings">客户端设置</mdui-list-item>
|
||||||
<mdui-list-item rounded icon="group_add">创建新的群组</mdui-list-item>
|
<mdui-list-item rounded icon="person_add" onClick={() => AppStateRef.current!.openAddFavouriteChat()}>添加收藏对话</mdui-list-item>
|
||||||
<Link to="/info/user?id=0960bd15-4527-4000-97a8-73110160296f"><mdui-list-item rounded icon="group_add">我是测试</mdui-list-item></Link>
|
<mdui-list-item rounded icon="group_add" onClick={() => AppStateRef.current!.openCreateGroup()}>创建新的群组</mdui-list-item>
|
||||||
<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>
|
</mdui-list>
|
||||||
<div style={{
|
<div style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -191,10 +190,14 @@ export default function Main() {
|
|||||||
* Default: 侧边列表
|
* Default: 侧边列表
|
||||||
*/
|
*/
|
||||||
<div style={isMobileUI() ? {
|
<div style={isMobileUI() ? {
|
||||||
display: 'flex',
|
|
||||||
height: 'calc(100% - 80px - 67px)',
|
height: 'calc(100% - 80px - 67px)',
|
||||||
width: '100%',
|
marginLeft: '15px',
|
||||||
} : {}} id="SideBar">
|
marginRight: '15px',
|
||||||
|
marginTop: '5px',
|
||||||
|
marginBottom: '5px',
|
||||||
|
} : {
|
||||||
|
paddingRight: '8px',
|
||||||
|
}} id="SideBar">
|
||||||
<RecentChatsList style={{
|
<RecentChatsList style={{
|
||||||
display: currentShowPage == 'Recents' ? undefined : 'none'
|
display: currentShowPage == 'Recents' ? undefined : 'none'
|
||||||
}} />
|
}} />
|
||||||
@@ -206,6 +209,24 @@ export default function Main() {
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
!isMobileUI() && <div id="ChatFragment" style={{
|
||||||
|
display: "flex",
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
(state.currentSelectedChatId && state.currentSelectedChatId != '')
|
||||||
|
? <LazyChatFragment openedInDialog={false} chatId={state.currentSelectedChatId!} />
|
||||||
|
: <div style={{
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
}}>
|
||||||
|
选择以开始对话......
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Mobile: 底部导航栏提供列表切换
|
* Mobile: 底部导航栏提供列表切换
|
||||||
@@ -221,11 +242,11 @@ export default function Main() {
|
|||||||
</mdui-navigation-bar>
|
</mdui-navigation-bar>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}>
|
</DialogContextWrapper>
|
||||||
{subRoutes}
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
</MainSharedContext.Provider>
|
</MainSharedContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Main() {
|
||||||
|
return <Root />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Chat, UserMySelf } from "lingchair-client-protocol"
|
import { Chat } from "lingchair-client-protocol"
|
||||||
import { createContext } from "use-context-selector"
|
import { createContext } from "use-context-selector"
|
||||||
|
import { SharedState } from "./MainSharedReducer.ts"
|
||||||
|
|
||||||
type Shared = {
|
type Shared = {
|
||||||
functions_lazy: React.MutableRefObject<{
|
functions_lazy: React.MutableRefObject<{
|
||||||
@@ -7,14 +8,13 @@ type Shared = {
|
|||||||
updateRecentChats: () => void
|
updateRecentChats: () => void
|
||||||
updateAllChats: () => void
|
updateAllChats: () => void
|
||||||
}>
|
}>
|
||||||
favouriteChats: Chat[]
|
state: SharedState
|
||||||
setFavouriteChats: React.Dispatch<React.SetStateAction<Chat[]>>
|
|
||||||
setShowLoginDialog: React.Dispatch<React.SetStateAction<boolean>>
|
setShowLoginDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
setShowRegisterDialog: React.Dispatch<React.SetStateAction<boolean>>
|
setShowRegisterDialog: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
setShowAddFavourtieChatDialog: React.Dispatch<React.SetStateAction<boolean>>
|
|
||||||
setCurrentSelectedChatId: React.Dispatch<React.SetStateAction<string>>
|
setCurrentSelectedChatId: (id: string) => void
|
||||||
myProfileCache?: UserMySelf
|
setFavouriteChats: (chats: Chat[]) => void
|
||||||
currentSelectedChatId: string
|
|
||||||
}
|
}
|
||||||
const MainSharedContext = createContext({} as Shared)
|
const MainSharedContext = createContext({} as Shared)
|
||||||
|
|
||||||
|
|||||||
21
client/ui/MainSharedReducer.ts
Normal file
21
client/ui/MainSharedReducer.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Chat } from "lingchair-client-protocol"
|
||||||
|
|
||||||
|
export interface SharedState {
|
||||||
|
favouriteChats: Chat[]
|
||||||
|
currentSelectedChatId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'update_favourite_chat', data: Chat[] }
|
||||||
|
| { type: 'update_selected_chat_id', data: string }
|
||||||
|
|
||||||
|
export default function MainSharedReducer(state: SharedState, action: Action): SharedState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'update_favourite_chat':
|
||||||
|
return { ...state, favouriteChats: action.data }
|
||||||
|
case 'update_selected_chat_id':
|
||||||
|
return { ...state, currentSelectedChatId: action.data }
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
114
client/ui/MduiPatchedTextAreaElement.ts
Normal file
114
client/ui/MduiPatchedTextAreaElement.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { $ } from "mdui"
|
||||||
|
|
||||||
|
export default class MduiPatchedTextAreaElement extends HTMLElement {
|
||||||
|
static observedAttributes = ['value', 'placeholder']
|
||||||
|
declare inputDiv?: HTMLDivElement
|
||||||
|
declare inputContainerDiv?: HTMLDivElement
|
||||||
|
declare placeholder?: string | number | null
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastValue = ''
|
||||||
|
connectedCallback() {
|
||||||
|
const shadow = this.shadowRoot as ShadowRoot
|
||||||
|
|
||||||
|
this.inputContainerDiv = new DOMParser().parseFromString(`
|
||||||
|
<div style="overflow-y: auto; height: 100%;">
|
||||||
|
<div role="textbox" aria-multiline="true" aria-labelledby="txtboxMultilineLabel" contentEditable="true" style="outline: none !important; color: rgb(var(--mdui-color-on-surface-variant)); display: inline-block; word-break: break-word; white-space: pre-wrap;"></div>
|
||||||
|
<style>
|
||||||
|
[contenteditable="true"]:empty:before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
width: 6px;
|
||||||
|
background: rgba(#101f1c, 0.1);
|
||||||
|
-webkit-border-radius: 2em;
|
||||||
|
-moz-border-radius: 2em;
|
||||||
|
border-radius: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(144, 147, 153, 0.5);
|
||||||
|
background-clip: padding-box;
|
||||||
|
min-height: 28px;
|
||||||
|
-webkit-border-radius: 2em;
|
||||||
|
-moz-border-radius: 2em;
|
||||||
|
border-radius: 2em;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(144, 147, 153, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
`, 'text/html').body.firstChild as HTMLDivElement
|
||||||
|
|
||||||
|
this.inputDiv = this.inputContainerDiv.children[0] as HTMLDivElement
|
||||||
|
|
||||||
|
this.inputDiv.addEventListener('blur', () => {
|
||||||
|
if (this._lastValue !== this.value) {
|
||||||
|
this._lastValue = this.value || ''
|
||||||
|
this.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
}
|
||||||
|
// 消除 <br> 对 placeholder 的影响
|
||||||
|
if (this.value == '')
|
||||||
|
this.value = ''
|
||||||
|
})
|
||||||
|
this.inputDiv.addEventListener('paste', (e: ClipboardEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
document.execCommand('insertText', false, e.clipboardData?.getData("text/plain") || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.inputDiv.style.width = '100%'
|
||||||
|
|
||||||
|
$(this.inputDiv).attr('data-placeholder', $(this).attr('placeholder'))
|
||||||
|
|
||||||
|
shadow.appendChild(this.inputContainerDiv)
|
||||||
|
}
|
||||||
|
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
||||||
|
// console.log(this.inputDiv, name, oldValue, newValue)
|
||||||
|
switch (name) {
|
||||||
|
case 'value': {
|
||||||
|
this.value = newValue || ''
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'placeholder': {
|
||||||
|
this.inputDiv && $(this.inputDiv).attr('data-placeholder', newValue)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus() {
|
||||||
|
this.inputDiv?.focus()
|
||||||
|
}
|
||||||
|
blur() {
|
||||||
|
this.inputDiv?.blur()
|
||||||
|
}
|
||||||
|
checkValidity() {
|
||||||
|
// TODO: implment this method
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
get value() {
|
||||||
|
return this.inputDiv?.textContent || ''
|
||||||
|
}
|
||||||
|
set value(v) {
|
||||||
|
this.inputDiv && (this.inputDiv.textContent = v)
|
||||||
|
}
|
||||||
|
insertHtml(html: string) {
|
||||||
|
this.inputDiv?.focus()
|
||||||
|
|
||||||
|
document.execCommand('insertHTML', false, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('mdui-patched-textarea', MduiPatchedTextAreaElement)
|
||||||
11
client/ui/ProgressDialogFallback.tsx
Normal file
11
client/ui/ProgressDialogFallback.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import EffectOnly from "./EffectOnly.tsx"
|
||||||
|
import showCircleProgressDialog from "./showCircleProgressDialog.ts"
|
||||||
|
|
||||||
|
export default function ProgressDialogFallback({ text }: { text: string }) {
|
||||||
|
return <EffectOnly effect={() => {
|
||||||
|
const wait = showCircleProgressDialog(text)
|
||||||
|
return () => {
|
||||||
|
wait.open = false
|
||||||
|
}
|
||||||
|
}} deps={[]} />
|
||||||
|
}
|
||||||
40
client/ui/app-state/AddFavourtieChatDialog.tsx
Normal file
40
client/ui/app-state/AddFavourtieChatDialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Dialog, TextField } from "mdui"
|
||||||
|
import showSnackbar from '../../utils/showSnackbar.ts'
|
||||||
|
import { CallbackError } from 'lingchair-client-protocol'
|
||||||
|
import useEventListener from '../../utils/useEventListener.ts'
|
||||||
|
import ClientCache from '../../ClientCache.ts'
|
||||||
|
|
||||||
|
export default function AddFavourtieChatDialog({ useRef }: { useRef: React.MutableRefObject<Dialog | undefined> }) {
|
||||||
|
const inputTargetRef = React.useRef<TextField>(null)
|
||||||
|
|
||||||
|
useEventListener(useRef, 'closed', () => {
|
||||||
|
inputTargetRef.current!.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addFavouriteChat() {
|
||||||
|
try {
|
||||||
|
await (await ClientCache.getMySelf())!.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="添加收藏对话" ref={useRef}>
|
||||||
|
<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={() => useRef.current!.open = false}>取消</mdui-button>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => addFavouriteChat()}>添加</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
client/ui/app-state/AppStateContext.ts
Normal file
26
client/ui/app-state/AppStateContext.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Chat, User } from 'lingchair-client-protocol'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
type AppState = {
|
||||||
|
openChatInfo: (chat: Chat | string) => void
|
||||||
|
openUserInfo: (user: Chat | User | string) => void
|
||||||
|
openEditMyProfile: () => void
|
||||||
|
openAddFavouriteChat: () => void
|
||||||
|
openCreateGroup: () => void
|
||||||
|
openChat: (chat: string | Chat, inDialog?: boolean) => void
|
||||||
|
closeChat: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppStateContext = React.createContext<AppState>({
|
||||||
|
openChatInfo: () => {},
|
||||||
|
openUserInfo: () => {},
|
||||||
|
openEditMyProfile: () => {},
|
||||||
|
openAddFavouriteChat: () => {},
|
||||||
|
openCreateGroup: () => {},
|
||||||
|
openChat: () => {},
|
||||||
|
closeChat: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type { AppState }
|
||||||
|
|
||||||
|
export default AppStateContext
|
||||||
98
client/ui/app-state/AppStateContextWrapper.tsx
Normal file
98
client/ui/app-state/AppStateContextWrapper.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { $, Dialog } from "mdui"
|
||||||
|
import AppStateContext, { AppState } from "./AppStateContext.ts"
|
||||||
|
import { Chat, User } from "lingchair-client-protocol"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import UserOrChatInfoDialog from "./UserOrChatInfoDialog.tsx"
|
||||||
|
import useEffectRef from "../../utils/useEffectRef.ts"
|
||||||
|
import EditMyProfileDialog from "./EditMyProfileDialog.tsx"
|
||||||
|
import AddFavourtieChatDialog from "./AddFavourtieChatDialog.tsx"
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useContextSelector } from "use-context-selector"
|
||||||
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
import ChatFragmentDialog from "./ChatFragmentDialog.tsx"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import CreateGroupDialog from "./CreateGroupDialog.tsx"
|
||||||
|
|
||||||
|
const config = await fetch('/config.json').then((re) => re.json())
|
||||||
|
|
||||||
|
export default function DialogContextWrapper({ children, useRef }: { children: React.ReactNode, useRef: React.MutableRefObject<AppState | undefined> }) {
|
||||||
|
const [userOrChatInfoDialogState, setUserOrChatInfoDialogState] = React.useState<Chat[]>([])
|
||||||
|
const lastUserOrChatInfoDialogStateRef = React.useRef<Chat>()
|
||||||
|
const userOrChatInfoDialogRef = useEffectRef<Dialog>((ref) => {
|
||||||
|
ref.current!.addEventListener('closed', () => {
|
||||||
|
setUserOrChatInfoDialogState([])
|
||||||
|
})
|
||||||
|
ref.current!.addEventListener('overlay-click', () => {
|
||||||
|
ref.current!.open = false
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
React.useEffect(() => {
|
||||||
|
userOrChatInfoDialogState.length != 0 && (lastUserOrChatInfoDialogStateRef.current = userOrChatInfoDialogState[userOrChatInfoDialogState.length - 1])
|
||||||
|
userOrChatInfoDialogRef.current!.open = userOrChatInfoDialogState.length != 0
|
||||||
|
}, [userOrChatInfoDialogState])
|
||||||
|
|
||||||
|
const editMyProfileDialogRef = React.useRef<Dialog>()
|
||||||
|
const addFavouriteChatDialogRef = React.useRef<Dialog>()
|
||||||
|
const createGroupDialogRef = React.useRef<Dialog>()
|
||||||
|
|
||||||
|
const setCurrentSelectedChatId = useContextSelector(
|
||||||
|
MainSharedContext,
|
||||||
|
(context: Shared) => context.setCurrentSelectedChatId
|
||||||
|
)
|
||||||
|
const currentSelectedChatId = useContextSelector(
|
||||||
|
MainSharedContext,
|
||||||
|
(context: Shared) => context.state.currentSelectedChatId
|
||||||
|
)
|
||||||
|
const chatFragmentDialogRef = React.useRef<Dialog>()
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
document.title = (currentSelectedChatId && currentSelectedChatId != '' && await ClientCache.getChat(currentSelectedChatId).then((v) => v?.getTitle()) + ' | ') + (config.title || 'LingChair')
|
||||||
|
}, [currentSelectedChatId])
|
||||||
|
|
||||||
|
return <AppStateContext.Provider value={useRef.current = class {
|
||||||
|
static async openChatInfo(chat: Chat | string) {
|
||||||
|
if (!(chat instanceof Chat))
|
||||||
|
chat = (await Chat.getById(getClient(), chat))!
|
||||||
|
|
||||||
|
setUserOrChatInfoDialogState([...userOrChatInfoDialogState, chat])
|
||||||
|
}
|
||||||
|
static async openUserInfo(user: Chat | User | string) {
|
||||||
|
if (typeof user == 'string') user = (await Chat.getOrCreatePrivateChat(getClient(), user))!
|
||||||
|
else if (user instanceof User) user = (await Chat.getOrCreatePrivateChat(getClient(), user.getId()))!
|
||||||
|
return this.openChatInfo(user)
|
||||||
|
}
|
||||||
|
static openEditMyProfile() {
|
||||||
|
editMyProfileDialogRef.current!.open = true
|
||||||
|
}
|
||||||
|
static openAddFavouriteChat() {
|
||||||
|
addFavouriteChatDialogRef.current!.open = true
|
||||||
|
}
|
||||||
|
static openCreateGroup() {
|
||||||
|
createGroupDialogRef.current!.open = true
|
||||||
|
}
|
||||||
|
static async openChat(chat: string | Chat, inDialog?: boolean) {
|
||||||
|
if (chat instanceof Chat) chat = chat.getId()
|
||||||
|
|
||||||
|
setUserOrChatInfoDialogState([])
|
||||||
|
setCurrentSelectedChatId(chat)
|
||||||
|
|
||||||
|
inDialog && (chatFragmentDialogRef.current!.open = true)
|
||||||
|
}
|
||||||
|
static closeChat() {
|
||||||
|
if (chatFragmentDialogRef.current!.open) {
|
||||||
|
chatFragmentDialogRef.current!.open = false
|
||||||
|
$(chatFragmentDialogRef.current!).one('closed', () => setCurrentSelectedChatId(''))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
setCurrentSelectedChatId('')
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<ChatFragmentDialog chatId={currentSelectedChatId} useRef={chatFragmentDialogRef} />
|
||||||
|
<UserOrChatInfoDialog chat={userOrChatInfoDialogState[userOrChatInfoDialogState.length - 1] || lastUserOrChatInfoDialogStateRef.current} useRef={userOrChatInfoDialogRef} />
|
||||||
|
<EditMyProfileDialog useRef={editMyProfileDialogRef} />
|
||||||
|
<AddFavourtieChatDialog useRef={addFavouriteChatDialogRef} />
|
||||||
|
<CreateGroupDialog useRef={createGroupDialogRef} />
|
||||||
|
{children}
|
||||||
|
</AppStateContext.Provider>
|
||||||
|
}
|
||||||
27
client/ui/app-state/ChatFragmentDialog.tsx
Normal file
27
client/ui/app-state/ChatFragmentDialog.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Dialog } from "mdui"
|
||||||
|
import * as React from 'react'
|
||||||
|
import LazyChatFragment from "../chat-fragment/LazyChatFragment.tsx"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
|
||||||
|
export default function ChatFragmentDialog({ chatId, useRef }: { chatId: string, useRef: React.MutableRefObject<Dialog | undefined> }) {
|
||||||
|
useEventListener(useRef, 'open', () => {
|
||||||
|
const shadow = useRef.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'
|
||||||
|
})
|
||||||
|
|
||||||
|
return <mdui-dialog fullscreen ref={useRef}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
}}>
|
||||||
|
{chatId != null && chatId != '' && <LazyChatFragment chatId={chatId} openedInDialog={true} />}
|
||||||
|
</div>
|
||||||
|
</mdui-dialog>
|
||||||
|
}
|
||||||
41
client/ui/app-state/CreateGroupDialog.tsx
Normal file
41
client/ui/app-state/CreateGroupDialog.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Dialog, TextField } from "mdui"
|
||||||
|
import showSnackbar from '../../utils/showSnackbar'
|
||||||
|
import { CallbackError, Chat } from 'lingchair-client-protocol'
|
||||||
|
import useEventListener from '../../utils/useEventListener.ts'
|
||||||
|
import getClient from '../../getClient.ts'
|
||||||
|
|
||||||
|
export default function CreateGroupDialog({ useRef }: { useRef: React.MutableRefObject<Dialog | undefined> }) {
|
||||||
|
const inputTargetRef = React.useRef<TextField>(null)
|
||||||
|
|
||||||
|
useEventListener(useRef, 'closed', () => {
|
||||||
|
inputTargetRef.current!.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createGroup() {
|
||||||
|
try {
|
||||||
|
await Chat.createGroupOrThrow(getClient(), inputTargetRef.current!.value)
|
||||||
|
inputTargetRef.current!.value = ''
|
||||||
|
showSnackbar({
|
||||||
|
message: '创建成功!'
|
||||||
|
})
|
||||||
|
useRef.current!.open = false
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '创建群组失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-dialog close-on-overlay-click close-on-esc headline="创建群组" ref={useRef}>
|
||||||
|
<mdui-text-field clearable label="群组标题" ref={inputTargetRef} onKeyDown={(event: KeyboardEvent) => {
|
||||||
|
if (event.key == 'Enter')
|
||||||
|
createGroup()
|
||||||
|
}}></mdui-text-field>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => useRef.current!.open = false}>取消</mdui-button>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => createGroup()}>创建</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
client/ui/app-state/EditMyProfileDialog.tsx
Normal file
99
client/ui/app-state/EditMyProfileDialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { CallbackError, UserMySelf } from "lingchair-client-protocol"
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import AvatarMySelf from "../AvatarMySelf.tsx"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
import { Dialog, TextField } from "mdui"
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export default function EditMyProfileDialog({ useRef }: { useRef: React.MutableRefObject<Dialog | undefined> }) {
|
||||||
|
const [mySelf, setMySelf] = React.useState<UserMySelf>()
|
||||||
|
useAsyncEffect(async () => setMySelf(await ClientCache.getMySelf() as UserMySelf))
|
||||||
|
|
||||||
|
const chooseAvatarFileRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const editNickNameRef = React.useRef<TextField>(null)
|
||||||
|
const editUserNameRef = React.useRef<TextField>(null)
|
||||||
|
|
||||||
|
useEventListener(chooseAvatarFileRef, 'change', async (_) => {
|
||||||
|
const file = chooseAvatarFileRef.current!.files?.[0] as File
|
||||||
|
if (file == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = await getClient().uploadFile({
|
||||||
|
fileName: 'UserAvatar',
|
||||||
|
fileData: file,
|
||||||
|
})
|
||||||
|
await mySelf?.setAvatarFileHashOrThrow(hash)
|
||||||
|
showSnackbar({
|
||||||
|
message: "修改成功, 刷新页面以更新",
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '上传头像失败: ' + e.message
|
||||||
|
})
|
||||||
|
showSnackbar({
|
||||||
|
message: '上传头像失败: ' + (e instanceof Error ? e.message : e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-dialog close-on-overlay-click close-on-esc ref={useRef}>
|
||||||
|
<div style={{
|
||||||
|
display: "none"
|
||||||
|
}}>
|
||||||
|
<input type="file" name="选择头像" ref={chooseAvatarFileRef}
|
||||||
|
accept="image/*" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<AvatarMySelf onClick={() => {
|
||||||
|
chooseAvatarFileRef.current!.value = ''
|
||||||
|
chooseAvatarFileRef.current!.click()
|
||||||
|
}} style={{
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
}} />
|
||||||
|
<mdui-text-field variant="outlined" placeholder="昵称" ref={editNickNameRef} style={{
|
||||||
|
marginLeft: "15px",
|
||||||
|
}} value={mySelf?.getNickName()}></mdui-text-field>
|
||||||
|
</div>
|
||||||
|
<mdui-divider style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
}}></mdui-divider>
|
||||||
|
|
||||||
|
<mdui-text-field style={{ marginTop: "10px", }} variant="outlined" label="用户 ID" value={mySelf?.getId() || ''} readonly onClick={(e: MouseEvent) => {
|
||||||
|
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={mySelf?.getUserName() || ''} ref={editUserNameRef}></mdui-text-field>
|
||||||
|
|
||||||
|
<mdui-button slot="action" variant="text" onClick={() => useRef.current!.open = false}>取消</mdui-button>
|
||||||
|
<mdui-button slot="action" variant="text" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await mySelf?.updateProfileOrThrow({
|
||||||
|
nickname: editNickNameRef.current?.value,
|
||||||
|
username: editUserNameRef.current?.value,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: '更新资料失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
showSnackbar({
|
||||||
|
message: "修改成功, 刷新页面以更新",
|
||||||
|
})
|
||||||
|
}}>更新</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
client/ui/app-state/UserOrChatInfoDialog.tsx
Normal file
112
client/ui/app-state/UserOrChatInfoDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Dialog, dialog } from "mdui"
|
||||||
|
import { CallbackError, Chat } from "lingchair-client-protocol"
|
||||||
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
|
import Avatar from "../Avatar.tsx"
|
||||||
|
import { useContextSelector } from "use-context-selector"
|
||||||
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
import * as React from 'react'
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import AppStateContext from "./AppStateContext.ts"
|
||||||
|
|
||||||
|
export default function UserOrChatInfoDialog({ chat, useRef }: { chat?: Chat, useRef: React.MutableRefObject<Dialog | undefined> }) {
|
||||||
|
const favouriteChats = useContextSelector(
|
||||||
|
MainSharedContext,
|
||||||
|
(context: Shared) => context.state.favouriteChats
|
||||||
|
)
|
||||||
|
|
||||||
|
const AppState = React.useContext(AppStateContext)
|
||||||
|
|
||||||
|
const [isMySelf, setIsMySelf] = React.useState(false)
|
||||||
|
const [id, setId] = React.useState('')
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
setIsMySelf(await ClientCache.getMySelf().then((re) => {
|
||||||
|
const id = re?.getId()!
|
||||||
|
setId(id)
|
||||||
|
return Chat.getOrCreatePrivateChat(getClient(), id)
|
||||||
|
}).then((re) => re?.getId()) == chat?.getId())
|
||||||
|
}, [chat])
|
||||||
|
|
||||||
|
const favourited = React.useMemo(() => favouriteChats.map((v) => v.getId()).indexOf(chat?.getId() || '') != -1, [chat, favouriteChats])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mdui-dialog ref={useRef}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Avatar src={getClient().getUrlForFileByHash(chat?.getAvatarFileHash())} text={chat?.getTitle()} style={{
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
marginLeft: '15px',
|
||||||
|
marginRight: '15px',
|
||||||
|
fontSize: '16.5px',
|
||||||
|
flexDirection: 'column',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '16.5px'
|
||||||
|
}}>{chat?.getTitle() + (isMySelf ? ' (我)' : '')}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10.5px',
|
||||||
|
marginTop: '3px',
|
||||||
|
color: 'rgb(var(--mdui-color-secondary))',
|
||||||
|
}}>({chat?.getType()}) ID: {chat?.getType() == 'private' ? id : chat?.getId()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mdui-divider style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
}}></mdui-divider>
|
||||||
|
<mdui-list>
|
||||||
|
{
|
||||||
|
isMySelf && <mdui-list-item icon="edit" rounded onClick={() => AppState.openEditMyProfile()}>
|
||||||
|
编辑资料
|
||||||
|
</mdui-list-item>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!isMySelf && <mdui-list-item icon={favourited ? "favorite_border" : "favorite"} rounded onClick={() => dialog({
|
||||||
|
headline: favourited ? "取消收藏对话" : "收藏对话",
|
||||||
|
description: favourited ? "确定从收藏对话列表中移除吗? (虽然这不会导致聊天记录丢失)" : "确定要添加到收藏对话列表吗?",
|
||||||
|
closeOnEsc: true,
|
||||||
|
closeOnOverlayClick: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: "取消",
|
||||||
|
onClick: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "确定",
|
||||||
|
onClick: () => {
|
||||||
|
; (async () => {
|
||||||
|
try {
|
||||||
|
if (favourited)
|
||||||
|
await (await ClientCache.getMySelf())!.removeFavouriteChatsOrThrow([chat?.getId()!])
|
||||||
|
else
|
||||||
|
await (await ClientCache.getMySelf())!.addFavouriteChatsOrThrow([chat?.getId()!])
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CallbackError)
|
||||||
|
showSnackbar({
|
||||||
|
message: (favourited ? "取消收藏对话" : "收藏对话") + '失败: ' + e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})}>{favourited ? '取消收藏' : '收藏对话'}</mdui-list-item>
|
||||||
|
}
|
||||||
|
<mdui-list-item icon="chat" rounded onClick={async () => {
|
||||||
|
AppState.openChat(chat!, isMobileUI())
|
||||||
|
}}>打开对话</mdui-list-item>
|
||||||
|
</mdui-list>
|
||||||
|
</mdui-dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import { $ } from 'mdui'
|
import { $ } from 'mdui'
|
||||||
import showSnackbar from "../../utils/showSnackbar.ts";
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
customElements.define('chat-mention', class extends HTMLElement {
|
import { Chat, User } from 'lingchair-client-protocol'
|
||||||
|
|
||||||
|
export default class ChatMentionElement extends HTMLElement {
|
||||||
declare link: HTMLAnchorElement
|
declare link: HTMLAnchorElement
|
||||||
static observedAttributes = ['user-id']
|
static observedAttributes = ['user-id']
|
||||||
|
|
||||||
|
// 这两个方法应当在被渲染后由渲染组件主动提供
|
||||||
|
declare openChatInfo?: (chat: Chat | string) => void
|
||||||
|
declare openUserInfo?: (user: Chat | User | string) => void
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -30,18 +37,14 @@ 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) {
|
||||||
|
|
||||||
this.link.onclick = (e) => {
|
this.link.onclick = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// deno-lint-ignore no-window
|
this.openChatInfo?.(chatId)
|
||||||
|
|
||||||
}
|
}
|
||||||
} else if (userId) {
|
} else if (userId) {
|
||||||
|
|
||||||
this.link.onclick = (e) => {
|
this.link.onclick = (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// deno-lint-ignore no-window
|
this.openUserInfo?.(userId)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,4 +60,6 @@ customElements.define('chat-mention', class extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
customElements.define('chat-mention', ChatMentionElement)
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ customElements.define('chat-text', class extends HTMLElement {
|
|||||||
|
|
||||||
// 避免不同的消息类型之间的换行符导致显示异常
|
// 避免不同的消息类型之间的换行符导致显示异常
|
||||||
if (isFirstElementInParent)
|
if (isFirstElementInParent)
|
||||||
this.span.textContent = this.textContent.trimStart()
|
this.span.textContent = (this.textContent || '').trimStart()
|
||||||
else if (isLastElementInParent)
|
else if (isLastElementInParent)
|
||||||
this.span.textContent = this.textContent.trimEnd()
|
this.span.textContent = (this.textContent || '').trimEnd()
|
||||||
else
|
else
|
||||||
this.span.textContent = this.textContent
|
this.span.textContent = this.textContent
|
||||||
this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : ''
|
this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : ''
|
||||||
|
|||||||
257
client/ui/chat-fragment/ChatFragment.tsx
Normal file
257
client/ui/chat-fragment/ChatFragment.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { $, Tab, TextField } from "mdui"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
import useEffectRef from "../../utils/useEffectRef.ts"
|
||||||
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
import { Chat } from "lingchair-client-protocol"
|
||||||
|
import Preference from "../preference/Preference.tsx"
|
||||||
|
import PreferenceHeader from "../preference/PreferenceHeader.tsx"
|
||||||
|
import PreferenceLayout from "../preference/PreferenceLayout.tsx"
|
||||||
|
import PreferenceUpdater from "../preference/PreferenceUpdater.tsx"
|
||||||
|
import SwitchPreference from "../preference/SwitchPreference.tsx"
|
||||||
|
import TextFieldPreference from "../preference/TextFieldPreference.tsx"
|
||||||
|
import * as React from 'react'
|
||||||
|
import ChatMessageContainer from "./ChatMessageContainer"
|
||||||
|
import AppStateContext from "../app-state/AppStateContext.ts"
|
||||||
|
import ChatPanel, { ChatPanelRef } from "./ChatPanel.tsx"
|
||||||
|
|
||||||
|
interface MduiTabFitSizeArgs extends React.HTMLAttributes<HTMLElement & Tab> {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
function MduiTabFitSize({ children, ...props }: MduiTabFitSizeArgs) {
|
||||||
|
return <mdui-tab {...props} style={{
|
||||||
|
...props?.style,
|
||||||
|
minWidth: 'fit-content',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</mdui-tab>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatFragment({
|
||||||
|
chatInfo,
|
||||||
|
openedInDialog,
|
||||||
|
}: {
|
||||||
|
chatInfo: Chat
|
||||||
|
openedInDialog: boolean
|
||||||
|
}) {
|
||||||
|
const AppState = React.useContext(AppStateContext)
|
||||||
|
const [tabItemSelected, setTabItemSelected] = React.useState('Chat')
|
||||||
|
const tabRef = React.useRef<Tab>()
|
||||||
|
useEventListener(tabRef, 'change', () => {
|
||||||
|
tabRef.current != null && setTabItemSelected(tabRef.current!.value as string)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chatPanelRef = React.useRef<HTMLElement>()
|
||||||
|
const inputRef = React.useRef<TextField>()
|
||||||
|
const chatPagePanelRef = React.useRef<ChatPanelRef>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息, 成功则清空文本
|
||||||
|
*/
|
||||||
|
async function performSendMessage() {
|
||||||
|
await chatInfo.sendMessageOrThrow(inputRef.current!.value)
|
||||||
|
inputRef.current!.value = ''
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 拉取更多消息
|
||||||
|
* 本质是修改获取的偏移?
|
||||||
|
* WIP
|
||||||
|
*/
|
||||||
|
async function pullMoreMessages() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<mdui-tabs ref={useEffectRef<HTMLElement>((ref) => {
|
||||||
|
$(ref.current!.shadowRoot).append(`<style>.container::after { height: 0 !important; }</style>`)
|
||||||
|
$(tabRef.current!.shadowRoot).append(`<style>.container::after { height: 0 !important; }</style>`)
|
||||||
|
; (!isMobileUI()) && $(tabRef.current!.shadowRoot).append(`<style>.no-scroll-bar::-webkit-scrollbar{width:0px !important}*::-webkit-scrollbar{width:7px;height:10px}*::-webkit-scrollbar-track{width:6px;background:rgba(#101f1c,0.1);-webkit-border-radius:2em;-moz-border-radius:2em;border-radius:2em}*::-webkit-scrollbar-thumb{background-color:rgba(144,147,153,0.5);background-clip:padding-box;min-height:28px;-webkit-border-radius:2em;-moz-border-radius:2em;border-radius:2em;transition:background-color 0.3s;cursor:pointer}*::-webkit-scrollbar-thumb:hover{background-color:rgba(144,147,153,0.3)}</style>`)
|
||||||
|
}, [])} style={{
|
||||||
|
position: 'sticky',
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
openedInDialog && <mdui-button-icon icon="arrow_back" onClick={() => AppState.closeChat()} 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%",
|
||||||
|
width: '100%',
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
chatInfo.isMember() ? <>
|
||||||
|
<MduiTabFitSize value="Chat">{chatInfo.getTitle()}</MduiTabFitSize>
|
||||||
|
{chatInfo.getType() == 'group' && chatInfo.isAdmin() && <MduiTabFitSize value="NewMemberRequests">加入请求</MduiTabFitSize>}
|
||||||
|
{chatInfo.getType() == 'group' && <MduiTabFitSize value="GroupMembers">群组成员</MduiTabFitSize>}
|
||||||
|
</>
|
||||||
|
: <MduiTabFitSize value="RequestJoin">{chatInfo.getTitle()}</MduiTabFitSize>
|
||||||
|
}
|
||||||
|
{chatInfo.getType() == 'group' && <MduiTabFitSize value="Settings">设置</MduiTabFitSize>}
|
||||||
|
</mdui-tabs>
|
||||||
|
<div style={{
|
||||||
|
flexGrow: '1',
|
||||||
|
}}></div>
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
<mdui-button-icon icon="open_in_new" onClick={() => {
|
||||||
|
window.open('/chat?id=' + chatInfo.getId(), '_blank')
|
||||||
|
}} style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginLeft: '5px',
|
||||||
|
marginRight: '5px',
|
||||||
|
}}></mdui-button-icon>
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
<mdui-button-icon icon="refresh" onClick={() => {
|
||||||
|
|
||||||
|
}} style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginLeft: '5px',
|
||||||
|
marginRight: '5px',
|
||||||
|
}}></mdui-button-icon>
|
||||||
|
<mdui-button-icon icon="info" onClick={() => AppState.openChatInfo(chatInfo.getId())} style={{
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginLeft: '5px',
|
||||||
|
marginRight: '5px',
|
||||||
|
}}></mdui-button-icon>
|
||||||
|
</mdui-tabs>
|
||||||
|
<mdui-tab-panel slot="panel" value="RequestJoin" style={{
|
||||||
|
display: tabItemSelected == "RequestJoin" ? "flex" : "none",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
{/* 非群成员 */}
|
||||||
|
</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: WheelEvent) => {
|
||||||
|
const scrollTop = (e.target as HTMLDivElement).scrollTop
|
||||||
|
if (scrollTop == 0) {
|
||||||
|
// 加载更多
|
||||||
|
chatPagePanelRef.current?.setOffset(chatPagePanelRef.current.getOffset() + 15)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: "15px",
|
||||||
|
}}>
|
||||||
|
{/* 这里显示一些提示 */}
|
||||||
|
</div>
|
||||||
|
<ChatPanel ref={chatPagePanelRef} chat={chatInfo} />
|
||||||
|
{
|
||||||
|
// 输入框
|
||||||
|
}
|
||||||
|
<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) => {
|
||||||
|
// 文件拽入
|
||||||
|
}}>
|
||||||
|
<mdui-text-field variant="outlined" use-patched-textarea placeholder="(。・ω・。)" autosize ref={inputRef} max-rows={6} onChange={() => {
|
||||||
|
if (inputRef.current?.value.trim() == '') {
|
||||||
|
// 清空缓存的文件
|
||||||
|
}
|
||||||
|
}} onKeyDown={(event: KeyboardEvent) => {
|
||||||
|
if (event.ctrlKey && event.key == 'Enter') {
|
||||||
|
// 发送消息
|
||||||
|
performSendMessage()
|
||||||
|
}
|
||||||
|
}} onPaste={(event: ClipboardEvent) => {
|
||||||
|
for (const item of event.clipboardData?.items || []) {
|
||||||
|
if (item.kind == 'file') {
|
||||||
|
event.preventDefault()
|
||||||
|
const file = item.getAsFile() as File
|
||||||
|
// 添加文件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
marginRight: '10px',
|
||||||
|
marginTop: '3px',
|
||||||
|
marginBottom: '3px',
|
||||||
|
}}>
|
||||||
|
</mdui-text-field>
|
||||||
|
<mdui-button-icon slot="end-icon" icon="attach_file" style={{
|
||||||
|
marginRight: '6px',
|
||||||
|
}} onClick={() => {
|
||||||
|
// 添加文件
|
||||||
|
}}></mdui-button-icon>
|
||||||
|
<mdui-button-icon icon="send" style={{
|
||||||
|
marginRight: '7px',
|
||||||
|
}} onClick={performSendMessage}></mdui-button-icon>
|
||||||
|
<div style={{
|
||||||
|
display: 'none'
|
||||||
|
}}>
|
||||||
|
<input accept="*/*" type="file" name="添加文件" multiple ></input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mdui-tab-panel>
|
||||||
|
{
|
||||||
|
chatInfo.getType() == '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.getType() == 'group' && <mdui-tab-panel slot="panel" value="NewMemberRequests" style={{
|
||||||
|
display: tabItemSelected == "NewMemberRequests" ? "flex" : "none",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
}}>
|
||||||
|
{/* {chatInfo.isAdmin() && <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="上传对话头像"></input>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
// 群组设置?
|
||||||
|
}
|
||||||
|
{
|
||||||
|
chatInfo.getType() == 'private' && (
|
||||||
|
<div>
|
||||||
|
未制作
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</mdui-tab-panel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
271
client/ui/chat-fragment/ChatMessage.tsx
Normal file
271
client/ui/chat-fragment/ChatMessage.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { ChatParserTransformers, Message } from "lingchair-client-protocol"
|
||||||
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import Avatar from "../Avatar.tsx"
|
||||||
|
import AppStateContext from "../app-state/AppStateContext.ts"
|
||||||
|
import { Dropdown } from "mdui"
|
||||||
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import * as React from 'react'
|
||||||
|
import ChatMentionElement from "../chat-elements/chat-mention.ts"
|
||||||
|
|
||||||
|
function escapeHTML(str: string) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = str
|
||||||
|
const re = div.innerHTML
|
||||||
|
div.remove()
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将扁平化的渲染文本重新排版
|
||||||
|
*
|
||||||
|
* 旨在优化图片, 文件, 视频等消息元素的显示
|
||||||
|
*
|
||||||
|
* @param html
|
||||||
|
* @returns { string }
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
'chat-quote',
|
||||||
|
]
|
||||||
|
function checkContinuousElement(tagName: string) {
|
||||||
|
// 如果上一个元素的类型和当前不一致, 或者上一个元素的类型和这个元素的类型都属于文本类型 (亦或者到最后一步时) 执行
|
||||||
|
if ((lastElementType != tagName || (textElementTags.indexOf(lastElementType) != -1 && textElementTags.indexOf(tagName) != -1)) || tagName == 'LAST_CHICKEN') {
|
||||||
|
// 如果上一个元素类型为文本类型, 且当前不是文本类型时, 用文本块包裹
|
||||||
|
if (textElementTags.indexOf(lastElementType) != -1) {
|
||||||
|
// 当前的文本类型不应该和上一个分离, 滚出去
|
||||||
|
if (textElementTags.indexOf(tagName) != -1) return
|
||||||
|
// 由于 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) {
|
||||||
|
// 当出现非文本元素时, 将文本聚合在一起
|
||||||
|
// 如果是其他类型, 虽然也执行聚合, 但是不会有外层包裹
|
||||||
|
checkContinuousElement(e.nodeName.toLowerCase())
|
||||||
|
ls.push(e)
|
||||||
|
lastElementType = e.nodeName.toLowerCase()
|
||||||
|
}
|
||||||
|
// 最后将剩余的转换
|
||||||
|
checkContinuousElement('LAST_CHICKEN')
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeConfig = {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
"chat-image",
|
||||||
|
"chat-video",
|
||||||
|
"chat-file",
|
||||||
|
'chat-text',
|
||||||
|
"chat-link",
|
||||||
|
'chat-mention',
|
||||||
|
'chat-quote',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'underline',
|
||||||
|
'em',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
'href',
|
||||||
|
'name',
|
||||||
|
'user-id',
|
||||||
|
'chat-id',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformers: ChatParserTransformers = {
|
||||||
|
attachment({ fileType, attachment }) {
|
||||||
|
const url = getClient().getUrlForFileByHash(attachment.getFileHash())
|
||||||
|
return ({
|
||||||
|
Image: `<chat-image src="${url}" alt="${attachment.getFileName()}"></chat-image>`,
|
||||||
|
Video: `<chat-video src="${url}"></chat-video>`,
|
||||||
|
File: `<chat-file href="${url}" name="${attachment.getFileName()}"></chat-file>`,
|
||||||
|
})?.[fileType]
|
||||||
|
},
|
||||||
|
mention({ mentionType, mention }) {
|
||||||
|
switch (mentionType) {
|
||||||
|
case "UserMention":
|
||||||
|
return `<chat-mention user-id="${mention.user_id}" text="${mention.text}">[对话提及]</chat-mention>`
|
||||||
|
case "ChatMention":
|
||||||
|
return `<chat-mention chat-id="${mention.chat_id}" text="${mention.text}">[对话提及]</chat-mention>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatMessage({ message, noUserDisplay, avatarMenuItems, messageMenuItems }: { message: Message, noUserDisplay?: boolean, avatarMenuItems?: globalThis.React.JSX.IntrinsicElements['mdui-menu-item'][], messageMenuItems?: globalThis.React.JSX.IntrinsicElements['mdui-menu-item'][] }) {
|
||||||
|
const AppState = React.useContext(AppStateContext)
|
||||||
|
|
||||||
|
const [show, setShown] = React.useState(false)
|
||||||
|
|
||||||
|
const [isAtRight, setAtRight] = React.useState(false)
|
||||||
|
|
||||||
|
const messageDropDownRef = React.useRef<Dropdown>()
|
||||||
|
const [isMessageDropDownOpen, setMessageDropDownOpen] = React.useState(false)
|
||||||
|
useEventListener(messageDropDownRef, 'closed', () => {
|
||||||
|
setMessageDropDownOpen(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarDropDownRef = React.useRef<Dropdown>()
|
||||||
|
const [isAvatarDropDownOpen, setAvatarDropDownOpen] = React.useState(false)
|
||||||
|
useEventListener(avatarDropDownRef, 'closed', () => {
|
||||||
|
setAvatarDropDownOpen(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const [nickName, setNickName] = React.useState(message.getUserId()! || 'System')
|
||||||
|
const [avatarUrl, setAvatarUrl] = React.useState('')
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
const user = await ClientCache.getUser(message.getUserId()!)
|
||||||
|
setAtRight(await ClientCache.getMySelf().then((re) => re?.getId()) == user?.getId())
|
||||||
|
setNickName(user?.getNickName() || '')
|
||||||
|
setAvatarUrl(getClient().getUrlForFileByHash(user?.getAvatarFileHash() || '') || '')
|
||||||
|
setShown(true)
|
||||||
|
}, [message])
|
||||||
|
|
||||||
|
const messageInnerRef = React.useRef<HTMLSpanElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
messageInnerRef.current!.innerHTML = prettyFlatParsedMessage(DOMPurify.sanitize(message.parseWithTransformers(transformers), sanitizeConfig))
|
||||||
|
|
||||||
|
// 没有办法的办法 (笑)
|
||||||
|
// 姐姐, 谁让您不是 React 组件呢
|
||||||
|
messageInnerRef.current!.querySelectorAll('chat-mention').forEach((v) => {
|
||||||
|
const e = v as ChatMentionElement
|
||||||
|
e.openChatInfo = AppState.openChatInfo
|
||||||
|
e.openUserInfo = AppState.openUserInfo
|
||||||
|
})
|
||||||
|
}, [message])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div style={{
|
||||||
|
display: show ? 'none' : undefined,
|
||||||
|
padding: '5px',
|
||||||
|
}}>加载中...</div>
|
||||||
|
<div
|
||||||
|
slot="trigger"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (isMobileUI()) return
|
||||||
|
e.preventDefault()
|
||||||
|
setMessageDropDownOpen(!isMessageDropDownOpen)
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!isMobileUI()) return
|
||||||
|
e.preventDefault()
|
||||||
|
setMessageDropDownOpen(!isMessageDropDownOpen)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: show ? 'flex' : 'none',
|
||||||
|
justifyContent: isAtRight ? "flex-end" : "flex-start",
|
||||||
|
flexDirection: "column"
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: noUserDisplay ? "none" : "flex",
|
||||||
|
justifyContent: isAtRight ? "flex-end" : "flex-start",
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
// 发送者昵称(左)
|
||||||
|
isAtRight && <span
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
fontSize: "90%"
|
||||||
|
}}>
|
||||||
|
{nickName}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// 发送者头像
|
||||||
|
}
|
||||||
|
<mdui-dropdown trigger="manual" ref={avatarDropDownRef} open={isAvatarDropDownOpen}>
|
||||||
|
<Avatar
|
||||||
|
slot="trigger"
|
||||||
|
src={avatarUrl}
|
||||||
|
text={nickName}
|
||||||
|
style={{
|
||||||
|
width: "43px",
|
||||||
|
height: "43px",
|
||||||
|
margin: "11px"
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (isMobileUI()) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setAvatarDropDownOpen(!isAvatarDropDownOpen)
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
AppState.openUserInfo(message.getUserId()!)
|
||||||
|
}} />
|
||||||
|
<mdui-menu>
|
||||||
|
{avatarMenuItems}
|
||||||
|
</mdui-menu>
|
||||||
|
</mdui-dropdown>
|
||||||
|
{
|
||||||
|
// 发送者昵称(右)
|
||||||
|
!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: noUserDisplay ? '5px' : "-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={messageDropDownRef} open={isMessageDropDownOpen}>
|
||||||
|
<span
|
||||||
|
slot="trigger"
|
||||||
|
id="msg"
|
||||||
|
style={{
|
||||||
|
fontSize: "94%",
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
ref={messageInnerRef} />
|
||||||
|
<mdui-menu onClick={(e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMessageDropDownOpen(false)
|
||||||
|
}}>
|
||||||
|
{messageMenuItems}
|
||||||
|
</mdui-menu>
|
||||||
|
</mdui-dropdown>
|
||||||
|
</mdui-card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
108
client/ui/chat-fragment/ChatMessageContainer.tsx
Normal file
108
client/ui/chat-fragment/ChatMessageContainer.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Message } from 'lingchair-client-protocol'
|
||||||
|
import * as React from 'react'
|
||||||
|
import ChatMessage from './ChatMessage.tsx'
|
||||||
|
import { dialog } from 'mdui'
|
||||||
|
|
||||||
|
export default function ChatMessageContainer({ messages }: { messages: Message[] }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
paddingTop: "15px",
|
||||||
|
flexGrow: '1',
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
(() => {
|
||||||
|
// 添加时间
|
||||||
|
let date = new Date(0)
|
||||||
|
function timeAddZeroPrefix(t: number) {
|
||||||
|
if (t >= 0 && t < 10)
|
||||||
|
return '0' + t
|
||||||
|
return t + ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并同用户消息
|
||||||
|
let user: string | undefined
|
||||||
|
return messages?.map((msg) => {
|
||||||
|
// 添加时间
|
||||||
|
const lastDate = date
|
||||||
|
date = new Date(msg.getTime())
|
||||||
|
const shouldShowTime = msg.getUserId() != null &&
|
||||||
|
(date.getMinutes() != lastDate.getMinutes() || date.getDate() != lastDate.getDate() || date.getMonth() != lastDate.getMonth() || date.getFullYear() != lastDate.getFullYear())
|
||||||
|
|
||||||
|
// 合并同用户消息
|
||||||
|
const lastUser = user
|
||||||
|
user = msg.getUserId()
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{
|
||||||
|
shouldShowTime && <mdui-tooltip content={`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '87%',
|
||||||
|
marginTop: '13px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
(date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}年` : '')
|
||||||
|
+ `${date.getMonth() + 1}月`
|
||||||
|
+ `${date.getDate()}日`
|
||||||
|
+ ` ${timeAddZeroPrefix(date.getHours())}:${timeAddZeroPrefix(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mdui-tooltip>
|
||||||
|
}
|
||||||
|
<ChatMessage
|
||||||
|
message={msg}
|
||||||
|
noUserDisplay={lastUser == user && !shouldShowTime}
|
||||||
|
avatarMenuItems={[
|
||||||
|
<mdui-menu-item icon="info" onClick={async () => {
|
||||||
|
const user = await msg.getUser().then((re) => re?.bean) || {}
|
||||||
|
dialog({
|
||||||
|
headline: "Info",
|
||||||
|
body: `<span style="word-break: break-word;">${Object.keys(user)
|
||||||
|
// @ts-ignore 懒
|
||||||
|
.map((k) => `${k} = ${user[k]}`)
|
||||||
|
.join('<br><br>')}<span>`,
|
||||||
|
closeOnEsc: true,
|
||||||
|
closeOnOverlayClick: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: "关闭",
|
||||||
|
onClick: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).addEventListener('click', (e) => e.stopPropagation())
|
||||||
|
}}>JSON</mdui-menu-item>
|
||||||
|
]}
|
||||||
|
messageMenuItems={[
|
||||||
|
<mdui-menu-item icon="info" onClick={() => dialog({
|
||||||
|
headline: "Info",
|
||||||
|
body: `<span style="word-break: break-word;">${Object.keys(msg.bean)
|
||||||
|
// @ts-ignore 懒
|
||||||
|
.map((k) => `${k} = ${msg.bean[k]}`)
|
||||||
|
.join('<br><br>')}<span>`,
|
||||||
|
closeOnEsc: true,
|
||||||
|
closeOnOverlayClick: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: "关闭",
|
||||||
|
onClick: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}).addEventListener('click', (e) => e.stopPropagation())}>Info</mdui-menu-item>
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
client/ui/chat-fragment/ChatPanel.tsx
Normal file
32
client/ui/chat-fragment/ChatPanel.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Chat, Message } from "lingchair-client-protocol"
|
||||||
|
import ChatMessageContainer from "./ChatMessageContainer.tsx"
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
function ChatPanelInner({ chat }: { chat: Chat }, ref: React.ForwardedRef<any>) {
|
||||||
|
const [messages, setMessages] = React.useState<Message[]>([])
|
||||||
|
const [offset, setOffset] = React.useState(0)
|
||||||
|
|
||||||
|
React.useImperativeHandle(ref, () => {
|
||||||
|
return {
|
||||||
|
setOffset: (offset: number) => setOffset(offset),
|
||||||
|
getOffset: () => offset,
|
||||||
|
}
|
||||||
|
}, [chat])
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
const messages = await chat.getMessagesOrThrow({ offset })
|
||||||
|
setMessages(messages)
|
||||||
|
}, [chat, offset])
|
||||||
|
|
||||||
|
return <ChatMessageContainer messages={messages} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatPanel = React.forwardRef(ChatPanelInner)
|
||||||
|
|
||||||
|
export type ChatPanelRef = {
|
||||||
|
setOffset: (offset: number) => void
|
||||||
|
getOffset: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatPanel
|
||||||
18
client/ui/chat-fragment/LazyChatFragment.tsx
Normal file
18
client/ui/chat-fragment/LazyChatFragment.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Chat } from "lingchair-client-protocol"
|
||||||
|
import getClient from "../../getClient.ts"
|
||||||
|
import ChatFragment from "./ChatFragment.tsx"
|
||||||
|
import * as React from 'react'
|
||||||
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
|
|
||||||
|
export default function LazyChatFragment({ chatId, openedInDialog }: { chatId: string, openedInDialog: boolean }) {
|
||||||
|
const [child, setChild] = React.useState<React.ReactNode>()
|
||||||
|
const chatInfoPromise = React.useMemo(() => Chat.getByIdOrThrow(getClient(), chatId), [chatId])
|
||||||
|
|
||||||
|
useAsyncEffect(async () => {
|
||||||
|
setChild(<ChatFragment chatInfo={await chatInfoPromise} openedInDialog={openedInDialog} />)
|
||||||
|
}, [chatId])
|
||||||
|
|
||||||
|
return <React.Suspense fallback={null}>
|
||||||
|
{child}
|
||||||
|
</React.Suspense>
|
||||||
|
}
|
||||||
64
client/ui/chat-fragment/settings/GroupSettingsFragment.tsx
Normal file
64
client/ui/chat-fragment/settings/GroupSettingsFragment.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export default function GroupSettingsFragment() {
|
||||||
|
/* chatInfo.getType() == 'group' && <PreferenceLayout>
|
||||||
|
<PreferenceUpdater.Provider value={groupPreferenceStore.createUpdater()}>
|
||||||
|
<PreferenceHeader
|
||||||
|
title="群组资料" />
|
||||||
|
<Preference
|
||||||
|
title="上传新的头像"
|
||||||
|
icon="image"
|
||||||
|
disabled={!chatInfo.isAdmin()}
|
||||||
|
onClick={() => {
|
||||||
|
uploadChatAvatarRef.current!.click()
|
||||||
|
}} />
|
||||||
|
<TextFieldPreference
|
||||||
|
title="设置群名称"
|
||||||
|
icon="edit"
|
||||||
|
id="group_title"
|
||||||
|
state={groupPreferenceStore.state.group_title || ''}
|
||||||
|
disabled={!chatInfo.isAdmin()} />
|
||||||
|
<TextFieldPreference
|
||||||
|
title="设置群别名"
|
||||||
|
icon="edit"
|
||||||
|
id="group_name"
|
||||||
|
description="以便于添加, 可留空"
|
||||||
|
state={groupPreferenceStore.state.group_name || ''}
|
||||||
|
disabled={!chatInfo.isAdmin()} />
|
||||||
|
<PreferenceHeader
|
||||||
|
title="入群设定" />
|
||||||
|
<SwitchPreference
|
||||||
|
title="允许入群"
|
||||||
|
icon="person_add"
|
||||||
|
id="allow_new_member_join"
|
||||||
|
disabled={!chatInfo.isAdmin()}
|
||||||
|
state={groupPreferenceStore.state.allow_new_member_join || false} />
|
||||||
|
<SwitchPreference
|
||||||
|
title="允许成员邀请"
|
||||||
|
description="目前压根没有这项功能, 甚至还不能查看成员列表, 以后再说吧"
|
||||||
|
id="allow_new_member_from_invitation"
|
||||||
|
icon="_"
|
||||||
|
disabled={true || !chatInfo.isAdmin()}
|
||||||
|
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.isAdmin() || !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.isAdmin()} />
|
||||||
|
}
|
||||||
|
</PreferenceUpdater.Provider>
|
||||||
|
</PreferenceLayout> */
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,23 +3,26 @@ import React from "react"
|
|||||||
import AllChatsListItem from "./AllChatsListItem.tsx"
|
import AllChatsListItem from "./AllChatsListItem.tsx"
|
||||||
import useEventListener from "../../utils/useEventListener.ts"
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
import { CallbackError, Chat } from "lingchair-client-protocol"
|
||||||
import getClient from "../../getClient.ts"
|
|
||||||
import showSnackbar from "../../utils/showSnackbar.ts"
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
import isMobileUI from "../../utils/isMobileUI.ts"
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
import { useContextSelector } from "use-context-selector"
|
import { useContextSelector } from "use-context-selector"
|
||||||
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import AppStateContext from "../app-state/AppStateContext.ts"
|
||||||
|
|
||||||
export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
myProfileCache: context.myProfileCache,
|
|
||||||
functions_lazy: context.functions_lazy,
|
functions_lazy: context.functions_lazy,
|
||||||
|
state: context.state,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const searchRef = React.useRef<HTMLElement>(null)
|
const searchRef = React.useRef<HTMLElement>(null)
|
||||||
const [searchText, setSearchText] = React.useState('')
|
const [searchText, setSearchText] = React.useState('')
|
||||||
const [allChatsList, setAllChatsList] = React.useState<Chat[]>([])
|
const [allChatsList, setAllChatsList] = React.useState<Chat[]>([])
|
||||||
|
|
||||||
|
const DialogState = React.useContext(AppStateContext)
|
||||||
|
|
||||||
useEventListener(searchRef, 'input', (e) => {
|
useEventListener(searchRef, 'input', (e) => {
|
||||||
setSearchText((e.target as unknown as TextField).value)
|
setSearchText((e.target as unknown as TextField).value)
|
||||||
})
|
})
|
||||||
@@ -27,7 +30,7 @@ export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElem
|
|||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
async function updateAllChats() {
|
async function updateAllChats() {
|
||||||
try {
|
try {
|
||||||
setAllChatsList(await shared.myProfileCache!.getMyAllChatsOrThrow())
|
setAllChatsList(await (await ClientCache.getMySelf())!.getMyAllChatsOrThrow())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof CallbackError)
|
if (e instanceof CallbackError)
|
||||||
if (e.code != 401 && e.code != 400)
|
if (e.code != 401 && e.code != 400)
|
||||||
@@ -54,7 +57,8 @@ export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElem
|
|||||||
...props?.style,
|
...props?.style,
|
||||||
}} {...props}>
|
}} {...props}>
|
||||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||||
paddingTop: '12px',
|
padding: isMobileUI() ? '12px' : undefined,
|
||||||
|
paddingTop: '4px',
|
||||||
paddingBottom: '13px',
|
paddingBottom: '13px',
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: '0',
|
top: '0',
|
||||||
@@ -68,10 +72,10 @@ export default function AllChatsList({ ...props }: React.HTMLAttributes<HTMLElem
|
|||||||
chat.getId().includes(searchText)
|
chat.getId().includes(searchText)
|
||||||
).map((v) =>
|
).map((v) =>
|
||||||
<AllChatsListItem
|
<AllChatsListItem
|
||||||
active={isMobileUI() ? false : currentChatId == v.getId()}
|
active={isMobileUI() ? false : shared.state.currentSelectedChatId == v.getId()}
|
||||||
key={v.getId()}
|
key={v.getId()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
openChatInfoDialog(v)
|
DialogState.openChatInfo(v.getId())
|
||||||
}}
|
}}
|
||||||
chat={v} />
|
chat={v} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,28 +3,28 @@ import FavouriteChatsListItem from "./FavouriteChatsListItem.tsx"
|
|||||||
import { dialog, TextField } from "mdui"
|
import { dialog, TextField } from "mdui"
|
||||||
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
import useEventListener from "../../utils/useEventListener.ts"
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
|
import { CallbackError, Chat } from "lingchair-client-protocol"
|
||||||
import showSnackbar from "../../utils/showSnackbar.ts"
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
import getClient from "../../getClient.ts"
|
|
||||||
import { useContextSelector } from "use-context-selector"
|
import { useContextSelector } from "use-context-selector"
|
||||||
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
import isMobileUI from "../../utils/isMobileUI.ts"
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import AppStateContext from "../app-state/AppStateContext.ts"
|
||||||
|
|
||||||
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
myProfileCache: context.myProfileCache,
|
state: context.state,
|
||||||
setShowAddFavourtieChatDialog: context.setShowAddFavourtieChatDialog,
|
|
||||||
functions_lazy: context.functions_lazy,
|
functions_lazy: context.functions_lazy,
|
||||||
currentSelectedChatId: context.currentSelectedChatId,
|
setFavouriteChats: context.setFavouriteChats,
|
||||||
values_lazy: context.values_lazy,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const searchRef = React.useRef<HTMLElement>(null)
|
const searchRef = React.useRef<HTMLElement>(null)
|
||||||
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
|
const [isMultiSelecting, setIsMultiSelecting] = React.useState(false)
|
||||||
const [searchText, setSearchText] = React.useState('')
|
const [searchText, setSearchText] = React.useState('')
|
||||||
const [favouriteChatsList, setFavouriteChatsList] = React.useState<Chat[]>([])
|
|
||||||
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
|
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
|
||||||
|
|
||||||
|
const AppState = React.useContext(AppStateContext)
|
||||||
|
|
||||||
useEventListener(searchRef, 'input', (e) => {
|
useEventListener(searchRef, 'input', (e) => {
|
||||||
setSearchText((e.target as unknown as TextField).value)
|
setSearchText((e.target as unknown as TextField).value)
|
||||||
})
|
})
|
||||||
@@ -32,9 +32,8 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
async function updateFavouriteChats() {
|
async function updateFavouriteChats() {
|
||||||
try {
|
try {
|
||||||
const ls = await shared.myProfileCache!.getMyFavouriteChatsOrThrow()
|
const ls = await (await ClientCache.getMySelf())!.getMyFavouriteChatsOrThrow()
|
||||||
setFavouriteChatsList(ls)
|
shared.setFavouriteChats(ls)
|
||||||
shared.favourite_chats
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof CallbackError)
|
if (e instanceof CallbackError)
|
||||||
if (e.code != 401 && e.code != 400)
|
if (e.code != 401 && e.code != 400)
|
||||||
@@ -50,7 +49,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
}
|
}
|
||||||
}, [shared.myProfileCache])
|
}, [])
|
||||||
|
|
||||||
return <mdui-list style={{
|
return <mdui-list style={{
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
@@ -68,12 +67,13 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
zIndex: '10',
|
zIndex: '10',
|
||||||
}}>
|
}}>
|
||||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||||
paddingTop: '12px',
|
padding: isMobileUI() ? '12px' : undefined,
|
||||||
|
paddingTop: '4px',
|
||||||
}}></mdui-text-field>
|
}}></mdui-text-field>
|
||||||
<mdui-list-item rounded style={{
|
<mdui-list-item rounded style={{
|
||||||
marginTop: '13px',
|
marginTop: '13px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}} icon="person_add" onClick={() => shared.setShowAddFavourtieChatDialog(true)}>添加收藏</mdui-list-item>
|
}} icon="person_add" onClick={() => AppState.openAddFavouriteChat()}>添加收藏</mdui-list-item>
|
||||||
<mdui-list-item rounded style={{
|
<mdui-list-item rounded style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}} icon="refresh" onClick={() => shared.functions_lazy.current.updateFavouriteChats()}>刷新列表</mdui-list-item>
|
}} icon="refresh" onClick={() => shared.functions_lazy.current.updateFavouriteChats()}>刷新列表</mdui-list-item>
|
||||||
@@ -106,7 +106,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
const ls = Object.keys(checkedList).filter((chatId) => checkedList[chatId] == true)
|
const ls = Object.keys(checkedList).filter((chatId) => checkedList[chatId] == true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
shared.myProfileCache!.removeFavouriteChatsOrThrow(ls)
|
(await ClientCache.getMySelf())!.removeFavouriteChatsOrThrow(ls)
|
||||||
|
|
||||||
setCheckedList({})
|
setCheckedList({})
|
||||||
setIsMultiSelecting(false)
|
setIsMultiSelecting(false)
|
||||||
@@ -118,7 +118,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
action: "撤销操作",
|
action: "撤销操作",
|
||||||
onActionClick: async () => {
|
onActionClick: async () => {
|
||||||
try {
|
try {
|
||||||
shared.myProfileCache!.addFavouriteChatsOrThrow(ls)
|
(await ClientCache.getMySelf())!.addFavouriteChatsOrThrow(ls)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof CallbackError)
|
if (e instanceof CallbackError)
|
||||||
showSnackbar({
|
showSnackbar({
|
||||||
@@ -146,13 +146,13 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
favouriteChatsList.filter((chat) =>
|
shared.state.favouriteChats.filter((chat) =>
|
||||||
searchText == '' ||
|
searchText == '' ||
|
||||||
chat.getTitle().includes(searchText) ||
|
chat.getTitle().includes(searchText) ||
|
||||||
chat.getId().includes(searchText)
|
chat.getId().includes(searchText)
|
||||||
).map((v) =>
|
).map((v) =>
|
||||||
<FavouriteChatsListItem
|
<FavouriteChatsListItem
|
||||||
active={isMultiSelecting ? checkedList[v.getId()] == true : (isMobileUI() ? false : shared.currentSelectedChatId == v.getId())}
|
active={isMultiSelecting ? checkedList[v.getId()] == true : (isMobileUI() ? false : shared.state.currentSelectedChatId == v.getId())}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isMultiSelecting)
|
if (isMultiSelecting)
|
||||||
setCheckedList({
|
setCheckedList({
|
||||||
@@ -160,7 +160,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
|
|||||||
[v.getId()]: !checkedList[v.getId()],
|
[v.getId()]: !checkedList[v.getId()],
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
openChatInfoDialog(v)
|
AppState.openChatInfo(v.getId())
|
||||||
}}
|
}}
|
||||||
key={v.getId()}
|
key={v.getId()}
|
||||||
chat={v} />
|
chat={v} />
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { TextField } from "mdui"
|
|||||||
import RecentsListItem from "./RecentsListItem.tsx"
|
import RecentsListItem from "./RecentsListItem.tsx"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
|
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
|
||||||
import { data } from "react-router"
|
|
||||||
import isMobileUI from "../../utils/isMobileUI.ts"
|
import isMobileUI from "../../utils/isMobileUI.ts"
|
||||||
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
|
||||||
import useEventListener from "../../utils/useEventListener.ts"
|
import useEventListener from "../../utils/useEventListener.ts"
|
||||||
@@ -10,12 +9,14 @@ import { CallbackError } from "lingchair-client-protocol"
|
|||||||
import { useContextSelector } from "use-context-selector"
|
import { useContextSelector } from "use-context-selector"
|
||||||
import showSnackbar from "../../utils/showSnackbar.ts"
|
import showSnackbar from "../../utils/showSnackbar.ts"
|
||||||
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
|
||||||
|
import ClientCache from "../../ClientCache.ts"
|
||||||
|
import AppStateContext from "../app-state/AppStateContext.ts"
|
||||||
|
|
||||||
export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
|
const AppState = React.useContext(AppStateContext)
|
||||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
myProfileCache: context.myProfileCache,
|
|
||||||
functions_lazy: context.functions_lazy,
|
functions_lazy: context.functions_lazy,
|
||||||
currentSelectedChatId: context.currentSelectedChatId,
|
state: context.state,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const searchRef = React.useRef<HTMLElement>(null)
|
const searchRef = React.useRef<HTMLElement>(null)
|
||||||
@@ -29,7 +30,7 @@ export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLE
|
|||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
async function updateRecents() {
|
async function updateRecents() {
|
||||||
try {
|
try {
|
||||||
setRecentsList(await shared.myProfileCache!.getMyRecentChats())
|
setRecentsList(await (await ClientCache.getMySelf())!.getMyRecentChats())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof CallbackError)
|
if (e instanceof CallbackError)
|
||||||
if (e.code != 401 && e.code != 400)
|
if (e.code != 401 && e.code != 400)
|
||||||
@@ -58,7 +59,8 @@ export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLE
|
|||||||
...props?.style,
|
...props?.style,
|
||||||
}} {...props}>
|
}} {...props}>
|
||||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||||
paddingTop: '12px',
|
padding: isMobileUI() ? '12px' : undefined,
|
||||||
|
paddingTop: '4px',
|
||||||
marginBottom: '13px',
|
marginBottom: '13px',
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: '0',
|
top: '0',
|
||||||
@@ -73,8 +75,8 @@ export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLE
|
|||||||
chat.getContent().includes(searchText)
|
chat.getContent().includes(searchText)
|
||||||
).map((v) =>
|
).map((v) =>
|
||||||
<RecentsListItem
|
<RecentsListItem
|
||||||
active={isMobileUI() ? false : shared.currentSelectedChatId == v.getId()}
|
active={isMobileUI() ? false : shared.state.currentSelectedChatId == v.getId()}
|
||||||
openChatFragment={() => openChatFragment(v.getId())}
|
onClick={() => AppState.openChat(v.getId(), isMobileUI())}
|
||||||
key={v.getId()}
|
key={v.getId()}
|
||||||
recentChat={v} />
|
recentChat={v} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Button, Dialog, TextField } from "mdui"
|
import { Dialog, TextField } from "mdui"
|
||||||
import MainSharedContext, { Shared } from '../MainSharedContext'
|
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
|
||||||
import showSnackbar from '../../utils/showSnackbar'
|
import showSnackbar from '../../utils/showSnackbar.ts'
|
||||||
import showCircleProgressDialog from '../showCircleProgressDialog'
|
import showCircleProgressDialog from '../showCircleProgressDialog.ts'
|
||||||
import getClient from '../../getClient'
|
import getClient from '../../getClient.ts'
|
||||||
import performAuth from '../../performAuth'
|
import performAuth from '../../performAuth.ts'
|
||||||
import { useContextSelector } from 'use-context-selector'
|
import { useContextSelector } from 'use-context-selector'
|
||||||
import useEventListener from '../../utils/useEventListener'
|
import useEventListener from '../../utils/useEventListener.ts'
|
||||||
|
|
||||||
export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
export default function RegisterDialog({ ...props }: { open: boolean } & React.HTMLAttributes<Dialog>) {
|
||||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
||||||
|
|||||||
16
client/ui/preference/Preference.tsx
Normal file
16
client/ui/preference/Preference.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
5
client/ui/preference/PreferenceHeader.tsx
Normal file
5
client/ui/preference/PreferenceHeader.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default function PreferenceHeader({ title }: {
|
||||||
|
title: string
|
||||||
|
}) {
|
||||||
|
return <mdui-list-subheader>{title}</mdui-list-subheader>
|
||||||
|
}
|
||||||
8
client/ui/preference/PreferenceLayout.tsx
Normal file
8
client/ui/preference/PreferenceLayout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default function PreferenceLayout({ children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
|
return <mdui-list style={{
|
||||||
|
marginLeft: '15px',
|
||||||
|
marginRight: '15px',
|
||||||
|
}} {...props}>
|
||||||
|
{children}
|
||||||
|
</mdui-list>
|
||||||
|
}
|
||||||
27
client/ui/preference/PreferenceStore.ts
Normal file
27
client/ui/preference/PreferenceStore.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/ui/preference/PreferenceUpdater.ts
Normal file
6
client/ui/preference/PreferenceUpdater.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
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
|
||||||
43
client/ui/preference/SelectPreference.tsx
Normal file
43
client/ui/preference/SelectPreference.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Dropdown } from 'mdui'
|
||||||
|
import PreferenceUpdater from "./PreferenceUpdater.ts"
|
||||||
|
import useEventListener from '../../utils/useEventListener.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: MouseEvent) => {
|
||||||
|
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>
|
||||||
|
}
|
||||||
30
client/ui/preference/SwitchPreference.tsx
Normal file
30
client/ui/preference/SwitchPreference.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
37
client/ui/preference/TextFieldPreference.tsx
Normal file
37
client/ui/preference/TextFieldPreference.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { dialog, Dialog } from "mdui"
|
|
||||||
import Avatar from "../Avatar.tsx"
|
|
||||||
import { CallbackError, Chat } from 'lingchair-client-protocol'
|
|
||||||
import { data, useLocation, useNavigate, useSearchParams } from 'react-router'
|
|
||||||
import useAsyncEffect from '../../utils/useAsyncEffect.ts'
|
|
||||||
import { useContextSelector } from 'use-context-selector'
|
|
||||||
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
|
|
||||||
import getClient from '../../getClient.ts'
|
|
||||||
import useEventListener from '../../utils/useEventListener.ts'
|
|
||||||
import showSnackbar from '../../utils/showSnackbar.ts'
|
|
||||||
|
|
||||||
export default function ChatInfoDialog({ ...props }: React.HTMLAttributes<HTMLElement>) {
|
|
||||||
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
|
|
||||||
myProfileCache: context.myProfileCache,
|
|
||||||
favouriteChats: context.favouriteChats,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [chat, setChat] = React.useState<Chat>()
|
|
||||||
const [userId, setUserId] = React.useState<string>()
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
let currentLocation = useLocation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
function back() {
|
|
||||||
navigate(-1)
|
|
||||||
}
|
|
||||||
const dialogRef = React.useRef<Dialog>()
|
|
||||||
useEventListener(dialogRef, 'overlay-click', () => back())
|
|
||||||
const id = searchParams.get('id')
|
|
||||||
|
|
||||||
const [favourited, setIsFavourited] = React.useState(false)
|
|
||||||
React.useEffect(() => {
|
|
||||||
setIsFavourited(shared.favouriteChats.map((v) => v.getId()).indexOf(chat?.getId() || '') != -1)
|
|
||||||
}, [chat, shared])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
console.log(currentLocation)
|
|
||||||
}, [currentLocation])
|
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
|
||||||
console.log(id, currentLocation.pathname)
|
|
||||||
try {
|
|
||||||
if (!currentLocation.pathname.startsWith('/info/')) {
|
|
||||||
dialogRef.current!.open = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id == null) {
|
|
||||||
dialogRef.current!.open = false
|
|
||||||
return back()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLocation.pathname.startsWith('/info/user')) {
|
|
||||||
setChat(await Chat.getOrCreatePrivateChatOrThrow(getClient(), id))
|
|
||||||
setUserId(id)
|
|
||||||
} else
|
|
||||||
setChat(await Chat.getByIdOrThrow(getClient(), id))
|
|
||||||
dialogRef.current!.open = true
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof CallbackError)
|
|
||||||
showSnackbar({
|
|
||||||
message: '打开资料卡失败: ' + e.message
|
|
||||||
})
|
|
||||||
console.log(e)
|
|
||||||
back()
|
|
||||||
}
|
|
||||||
}, [id, currentLocation])
|
|
||||||
|
|
||||||
if (!currentLocation.pathname.startsWith('/info/'))
|
|
||||||
return null
|
|
||||||
|
|
||||||
const avatarUrl = getClient().getUrlForFileByHash(chat?.getAvatarFileHash())!
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mdui-dialog ref={dialogRef}>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<Avatar src={avatarUrl} text={chat?.getTitle()} style={{
|
|
||||||
width: '50px',
|
|
||||||
height: '50px',
|
|
||||||
}} onClick={() => avatarUrl && openImageViewer(avatarUrl)} />
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
marginLeft: '15px',
|
|
||||||
marginRight: '15px',
|
|
||||||
fontSize: '16.5px',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '16.5px'
|
|
||||||
}}>{chat?.getTitle()}</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '10.5px',
|
|
||||||
marginTop: '3px',
|
|
||||||
color: 'rgb(var(--mdui-color-secondary))',
|
|
||||||
}}>({chat?.getType()}) ID: {chat?.getType() == 'private' ? userId : chat?.getId()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<mdui-divider style={{
|
|
||||||
marginTop: "10px",
|
|
||||||
}}></mdui-divider>
|
|
||||||
|
|
||||||
<mdui-list>
|
|
||||||
<mdui-list-item icon={favourited ? "favorite_border" : "favorite"} rounded onClick={() => dialog({
|
|
||||||
headline: favourited ? "取消收藏对话" : "收藏对话",
|
|
||||||
description: favourited ? "确定从收藏对话列表中移除吗? (虽然这不会导致聊天记录丢失)" : "确定要添加到收藏对话列表吗?",
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
text: "取消",
|
|
||||||
onClick: () => {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "确定",
|
|
||||||
onClick: () => {
|
|
||||||
; (async () => {
|
|
||||||
const re = await Client.invoke(favourited ? "User.removeContacts" : "User.addContacts", {
|
|
||||||
token: data.access_token,
|
|
||||||
targets: [
|
|
||||||
chat!.id
|
|
||||||
],
|
|
||||||
})
|
|
||||||
if (re.code != 200)
|
|
||||||
checkApiSuccessOrSncakbar(re, favourited ? "取消收藏失败" : "收藏失败")
|
|
||||||
EventBus.emit('ContactsList.updateContacts')
|
|
||||||
})()
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
})}>{favourited ? '取消收藏' : '收藏对话'}</mdui-list-item>
|
|
||||||
<mdui-list-item icon="chat" rounded onClick={() => {
|
|
||||||
chatInfoDialogRef.current!.open = false
|
|
||||||
openChatFragment(chat!.id)
|
|
||||||
}}>打开对话</mdui-list-item>
|
|
||||||
</mdui-list>
|
|
||||||
</mdui-dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import data from "../data.ts"
|
||||||
|
|
||||||
|
const searchParams = new URL(location.href).searchParams
|
||||||
|
|
||||||
export default function isMobileUI() {
|
export default function isMobileUI() {
|
||||||
const mobile = new URL(location.href).searchParams.get('mobile')
|
return data.override_use_mobile_ui || searchParams.get('mobile') == 'true' || /Mobi|Android|iPhone/i.test(navigator.userAgent)
|
||||||
if (mobile) return mobile == 'true'
|
|
||||||
return /Mobi|Android|iPhone/i.test(navigator.userAgent)
|
|
||||||
}
|
}
|
||||||
9
client/utils/useEffectRef.ts
Normal file
9
client/utils/useEffectRef.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
export default function useEffectRef<T = undefined>(effect: (ref: React.MutableRefObject<T | undefined>) => void | (() => void), deps?: React.DependencyList) {
|
||||||
|
const ref = React.useRef<T>()
|
||||||
|
React.useEffect(() => {
|
||||||
|
return effect(ref)
|
||||||
|
}, deps)
|
||||||
|
return ref
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
export default function useEventListener<T extends HTMLElement | undefined | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
|
export default function useEventListener<T extends HTMLElement | undefined | null>(ref: React.MutableRefObject<T>, eventName: string, callback: (event: Event) => void) {
|
||||||
|
// console.error(ref, eventName, callback)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// console.warn(ref, eventName, callback)
|
||||||
ref.current!.addEventListener(eventName, callback)
|
ref.current!.addEventListener(eventName, callback)
|
||||||
return () => ref.current?.removeEventListener(eventName, callback)
|
return () => ref.current?.removeEventListener(eventName, callback)
|
||||||
}, [ref, eventName, callback])
|
}, [ref, eventName, callback])
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export type CallMethod =
|
|||||||
"User.getInfo" |
|
"User.getInfo" |
|
||||||
|
|
||||||
// 收藏对话列表
|
// 收藏对话列表
|
||||||
"User.getMyContacts" |
|
"User.getMyFavouriteChats" |
|
||||||
"User.addContacts" |
|
"User.addFavouriteChats" |
|
||||||
"User.removeContacts" |
|
"User.removeFavouriteChats" |
|
||||||
|
|
||||||
// 最近对话列表
|
// 最近对话列表
|
||||||
"User.getMyRecentChats" |
|
"User.getMyRecentChats" |
|
||||||
@@ -36,7 +36,7 @@ export type CallMethod =
|
|||||||
|
|
||||||
// 对话创建
|
// 对话创建
|
||||||
"Chat.createGroup" |
|
"Chat.createGroup" |
|
||||||
"Chat.getIdForPrivate" |
|
"Chat.getOrCreatePrivateChat" |
|
||||||
|
|
||||||
// 入群请求
|
// 入群请求
|
||||||
"Chat.processJoinRequest" |
|
"Chat.processJoinRequest" |
|
||||||
@@ -51,9 +51,6 @@ export type CallMethod =
|
|||||||
"Chat.sendMessage" |
|
"Chat.sendMessage" |
|
||||||
"Chat.getMessageHistory"
|
"Chat.getMessageHistory"
|
||||||
|
|
||||||
// (废弃) 文件上传
|
|
||||||
// "Chat.uploadFile"
|
|
||||||
|
|
||||||
export type ClientEvent =
|
export type ClientEvent =
|
||||||
// 对话收消息
|
// 对话收消息
|
||||||
"Client.onMessage"
|
"Client.onMessage"
|
||||||
@@ -64,3 +61,5 @@ export const CallableMethodBeforeAuth = [
|
|||||||
"User.login",
|
"User.login",
|
||||||
"User.refreshAccessToken",
|
"User.refreshAccessToken",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const PageFetchMaxLimit = 15
|
||||||
|
|||||||
71
mdui_patched/components/dialog/index.js
vendored
71
mdui_patched/components/dialog/index.js
vendored
@@ -103,6 +103,7 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
]);
|
]);
|
||||||
// 打开
|
// 打开
|
||||||
// 要区分是否首次渲染,首次渲染不触发事件,不执行动画;非首次渲染,触发事件,执行动画
|
// 要区分是否首次渲染,首次渲染不触发事件,不执行动画;非首次渲染,触发事件,执行动画
|
||||||
|
this.panelRef.value.style.transformOrigin = 'center center';
|
||||||
if (this.open) {
|
if (this.open) {
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
const eventProceeded = this.emit('open', { cancelable: true });
|
const eventProceeded = this.emit('open', { cancelable: true });
|
||||||
@@ -136,8 +137,34 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
this.panelRef.value.focus({ preventScroll: true });
|
this.panelRef.value.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const duration = getDuration(this, 'medium4');
|
// const duration = getDuration(this, 'medium4');
|
||||||
|
const duration = hasUpdated ? getDuration(this, 'medium4') : 0;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
// 遮罩层淡入
|
||||||
|
animateTo(this.overlayRef.value, [
|
||||||
|
{ opacity: 0 },
|
||||||
|
{ opacity: 1 }
|
||||||
|
], {
|
||||||
|
duration: hasUpdated ? duration * 0.7 : 0,
|
||||||
|
easing: easingLinear,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 面板缩放淡入
|
||||||
|
animateTo(this.panelRef.value, [
|
||||||
|
{
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.8)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1)'
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
duration: hasUpdated ? duration : 0,
|
||||||
|
easing: easingEmphasizedDecelerate,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
/* await Promise.all([
|
||||||
animateTo(this.overlayRef.value, [{ opacity: 0 }, { opacity: 1, offset: 0.3 }, { opacity: 1 }], {
|
animateTo(this.overlayRef.value, [{ opacity: 0 }, { opacity: 1, offset: 0.3 }, { opacity: 1 }], {
|
||||||
duration: hasUpdated ? duration : 0,
|
duration: hasUpdated ? duration : 0,
|
||||||
easing: easingLinear,
|
easing: easingLinear,
|
||||||
@@ -162,7 +189,7 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
duration: hasUpdated ? duration : 0,
|
duration: hasUpdated ? duration : 0,
|
||||||
easing: easingLinear,
|
easing: easingLinear,
|
||||||
})),
|
})),
|
||||||
]);
|
]); */
|
||||||
if (hasUpdated) {
|
if (hasUpdated) {
|
||||||
this.emit('opened');
|
this.emit('opened');
|
||||||
}
|
}
|
||||||
@@ -174,8 +201,32 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
}
|
}
|
||||||
this.modalHelper.deactivate();
|
this.modalHelper.deactivate();
|
||||||
await stopAnimation();
|
await stopAnimation();
|
||||||
const duration = getDuration(this, 'short4');
|
// const duration = getDuration(this, 'short4');
|
||||||
|
const duration = getDuration(this, 'short3');
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
// 遮罩层淡出
|
||||||
|
animateTo(this.overlayRef.value, [
|
||||||
|
{ opacity: 1 },
|
||||||
|
{ opacity: 0 }
|
||||||
|
], {
|
||||||
|
duration,
|
||||||
|
easing: easingLinear,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 面板缩放淡出
|
||||||
|
animateTo(this.panelRef.value, [
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0,
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
duration,
|
||||||
|
easing: easingEmphasizedAccelerate,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
/* await Promise.all([
|
||||||
animateTo(this.overlayRef.value, [{ opacity: 1 }, { opacity: 0 }], {
|
animateTo(this.overlayRef.value, [{ opacity: 1 }, { opacity: 0 }], {
|
||||||
duration,
|
duration,
|
||||||
easing: easingLinear,
|
easing: easingLinear,
|
||||||
@@ -186,7 +237,7 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
], { duration, easing: easingEmphasizedAccelerate }),
|
], { duration, easing: easingEmphasizedAccelerate }),
|
||||||
animateTo(this.panelRef.value, [{ opacity: 1 }, { opacity: 1, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear }),
|
animateTo(this.panelRef.value, [{ opacity: 1 }, { opacity: 1, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear }),
|
||||||
...children.map((child) => animateTo(child, [{ opacity: 1 }, { opacity: 0, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear })),
|
...children.map((child) => animateTo(child, [{ opacity: 1 }, { opacity: 0, offset: 0.75 }, { opacity: 0 }], { duration, easing: easingLinear })),
|
||||||
]);
|
]); */
|
||||||
this.style.display = 'none';
|
this.style.display = 'none';
|
||||||
unlockScreen(this);
|
unlockScreen(this);
|
||||||
// 对话框关闭后,恢复焦点到原有的元素上
|
// 对话框关闭后,恢复焦点到原有的元素上
|
||||||
@@ -222,11 +273,11 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
const hasHeader = hasIcon || hasHeadline || this.hasSlotController.test('header');
|
const hasHeader = hasIcon || hasHeadline || this.hasSlotController.test('header');
|
||||||
const hasBody = hasDescription || hasDefaultSlot;
|
const hasBody = hasDescription || hasDefaultSlot;
|
||||||
// modify: 移除了 tabindex="0", 换为 tabindex
|
// modify: 移除了 tabindex="0", 换为 tabindex
|
||||||
return html `<div ${ref(this.overlayRef)} part="overlay" class="overlay" @click="${this.onOverlayClick}" tabindex="-1"></div><div ${ref(this.panelRef)} part="panel" class="panel ${classMap({
|
return html`<div ${ref(this.overlayRef)} part="overlay" class="overlay" @click="${this.onOverlayClick}" tabindex="-1"></div><div ${ref(this.panelRef)} part="panel" class="panel ${classMap({
|
||||||
'has-icon': hasIcon,
|
'has-icon': hasIcon,
|
||||||
'has-description': hasDescription,
|
'has-description': hasDescription,
|
||||||
'has-default': hasDefaultSlot,
|
'has-default': hasDefaultSlot,
|
||||||
})}" tabindex>${when(hasHeader, () => html `<slot name="header" part="header" class="header">${when(hasIcon, () => this.renderIcon())} ${when(hasHeadline, () => this.renderHeadline())}</slot>`)} ${when(hasBody, () => html `<div ${ref(this.bodyRef)} part="body" class="body">${when(hasDescription, () => this.renderDescription())}<slot></slot></div>`)} ${when(hasActionSlot, () => html `<slot name="action" part="action" class="action"></slot>`)}</div>`;
|
})}" tabindex>${when(hasHeader, () => html`<slot name="header" part="header" class="header">${when(hasIcon, () => this.renderIcon())} ${when(hasHeadline, () => this.renderHeadline())}</slot>`)} ${when(hasBody, () => html`<div ${ref(this.bodyRef)} part="body" class="body">${when(hasDescription, () => this.renderDescription())}<slot></slot></div>`)} ${when(hasActionSlot, () => html`<slot name="action" part="action" class="action"></slot>`)}</div>`;
|
||||||
}
|
}
|
||||||
onOverlayClick() {
|
onOverlayClick() {
|
||||||
this.emit('overlay-click');
|
this.emit('overlay-click');
|
||||||
@@ -236,15 +287,15 @@ let Dialog = class Dialog extends MduiElement {
|
|||||||
this.open = false;
|
this.open = false;
|
||||||
}
|
}
|
||||||
renderIcon() {
|
renderIcon() {
|
||||||
return html `<slot name="icon" part="icon" class="icon">${this.icon
|
return html`<slot name="icon" part="icon" class="icon">${this.icon
|
||||||
? html `<mdui-icon name="${this.icon}"></mdui-icon>`
|
? html`<mdui-icon name="${this.icon}"></mdui-icon>`
|
||||||
: nothingTemplate}</slot>`;
|
: nothingTemplate}</slot>`;
|
||||||
}
|
}
|
||||||
renderHeadline() {
|
renderHeadline() {
|
||||||
return html `<slot name="headline" part="headline" class="headline">${this.headline}</slot>`;
|
return html`<slot name="headline" part="headline" class="headline">${this.headline}</slot>`;
|
||||||
}
|
}
|
||||||
renderDescription() {
|
renderDescription() {
|
||||||
return html `<slot name="description" part="description" class="description">${this.description}</slot>`;
|
return html`<slot name="description" part="description" class="description">${this.description}</slot>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Dialog.styles = [componentStyle, style];
|
Dialog.styles = [componentStyle, style];
|
||||||
|
|||||||
65
mdui_patched/components/text-field/index.js
vendored
65
mdui_patched/components/text-field/index.js
vendored
@@ -398,9 +398,15 @@ let TextField = class TextField extends FocusableMixin(MduiElement) {
|
|||||||
'is-firefox': navigator.userAgent.includes('Firefox'),
|
'is-firefox': navigator.userAgent.includes('Firefox'),
|
||||||
...invalidClassNameObj,
|
...invalidClassNameObj,
|
||||||
});
|
});
|
||||||
return html `<div part="container" class="${className}">${this.renderPrefix()}<div class="input-container">${this.renderLabel()} ${this.isTextarea
|
return html`<div part="container" class="${className}">${this.renderPrefix()}<div class="input-container">${this.renderLabel()} ${!hasInputSlot ? (
|
||||||
|
this.getAttribute("use-patched-textarea")
|
||||||
|
? this.renderPatchedTextArea(hasInputSlot)
|
||||||
|
: (
|
||||||
|
this.isTextarea
|
||||||
? this.renderTextArea(hasInputSlot)
|
? this.renderTextArea(hasInputSlot)
|
||||||
: this.renderInput(hasInputSlot)} ${when(hasInputSlot, () => html `<slot name="input" class="input"></slot>`)}</div>${this.renderSuffix()}${this.renderClearButton(hasClearButton)} ${this.renderTogglePasswordButton(hasTogglePasswordButton)} ${this.renderRightIcon(hasErrorIcon)}</div>${when(hasError || hasHelper || hasCounter, () => html `<div part="supporting" class="${classMap({ supporting: true, ...invalidClassNameObj })}">${this.renderHelper(hasError, hasHelper)} ${this.renderCounter(hasCounter)}</div>`)}`;
|
: this.renderInput(hasInputSlot)
|
||||||
|
)
|
||||||
|
) : ''} ${when(hasInputSlot, () => html`<slot name="input" class="input"></slot>`)}</div>${this.renderSuffix()}${this.renderClearButton(hasClearButton)} ${this.renderTogglePasswordButton(hasTogglePasswordButton)} ${this.renderRightIcon(hasErrorIcon)}</div>${when(hasError || hasHelper || hasCounter, () => html`<div part="supporting" class="${classMap({ supporting: true, ...invalidClassNameObj })}">${this.renderHelper(hasError, hasHelper)} ${this.renderCounter(hasCounter)}</div>`)}`;
|
||||||
}
|
}
|
||||||
setCustomValidityInternal(message) {
|
setCustomValidityInternal(message) {
|
||||||
this.inputRef.value.setCustomValidity(message);
|
this.inputRef.value.setCustomValidity(message);
|
||||||
@@ -482,49 +488,54 @@ let TextField = class TextField extends FocusableMixin(MduiElement) {
|
|||||||
}
|
}
|
||||||
renderLabel() {
|
renderLabel() {
|
||||||
return this.label
|
return this.label
|
||||||
? html `<label part="label" class="label">${this.label}</label>`
|
? html`<label part="label" class="label">${this.label}</label>`
|
||||||
: nothingTemplate;
|
: nothingTemplate;
|
||||||
}
|
}
|
||||||
renderPrefix() {
|
renderPrefix() {
|
||||||
return html `<slot name="icon" part="icon" class="icon">${this.icon
|
return html`<slot name="icon" part="icon" class="icon">${this.icon
|
||||||
? html `<mdui-icon name="${this.icon}" class="i"></mdui-icon>`
|
? html`<mdui-icon name="${this.icon}" class="i"></mdui-icon>`
|
||||||
: nothingTemplate}</slot><slot name="prefix" part="prefix" class="prefix">${this.prefix}</slot>`;
|
: nothingTemplate}</slot><slot name="prefix" part="prefix" class="prefix">${this.prefix}</slot>`;
|
||||||
}
|
}
|
||||||
renderSuffix() {
|
renderSuffix() {
|
||||||
return html `<slot name="suffix" part="suffix" class="suffix">${this.suffix}</slot>`;
|
return html`<slot name="suffix" part="suffix" class="suffix">${this.suffix}</slot>`;
|
||||||
}
|
}
|
||||||
renderRightIcon(hasErrorIcon) {
|
renderRightIcon(hasErrorIcon) {
|
||||||
return hasErrorIcon
|
return hasErrorIcon
|
||||||
? html `<slot name="error-icon" part="error-icon" class="right-icon">${this.errorIcon
|
? html`<slot name="error-icon" part="error-icon" class="right-icon">${this.errorIcon
|
||||||
? html `<mdui-icon name="${this.errorIcon}" class="i"></mdui-icon>`
|
? html`<mdui-icon name="${this.errorIcon}" class="i"></mdui-icon>`
|
||||||
: html `<mdui-icon-error class="i"></mdui-icon-error>`}</slot>`
|
: html`<mdui-icon-error class="i"></mdui-icon-error>`}</slot>`
|
||||||
: html `<slot name="end-icon" part="end-icon" class="end-icon right-icon">${this.endIcon
|
: html`<slot name="end-icon" part="end-icon" class="end-icon right-icon">${this.endIcon
|
||||||
? html `<mdui-icon name="${this.endIcon}" class="i"></mdui-icon>`
|
? html`<mdui-icon name="${this.endIcon}" class="i"></mdui-icon>`
|
||||||
: nothingTemplate}</slot>`;
|
: nothingTemplate}</slot>`;
|
||||||
}
|
}
|
||||||
renderClearButton(hasClearButton) {
|
renderClearButton(hasClearButton) {
|
||||||
return when(hasClearButton, () => html `<slot name="clear-button" part="clear-button" class="action" @click="${this.onClear}"><mdui-button-icon tabindex="-1"><slot name="clear-icon" part="clear-icon">${this.clearIcon
|
return when(hasClearButton, () => html`<slot name="clear-button" part="clear-button" class="action" @click="${this.onClear}"><mdui-button-icon tabindex="-1"><slot name="clear-icon" part="clear-icon">${this.clearIcon
|
||||||
? html `<mdui-icon name="${this.clearIcon}" class="i"></mdui-icon>`
|
? html`<mdui-icon name="${this.clearIcon}" class="i"></mdui-icon>`
|
||||||
: html `<mdui-icon-cancel--outlined class="i"></mdui-icon-cancel--outlined>`}</slot></mdui-button-icon></slot>`);
|
: html`<mdui-icon-cancel--outlined class="i"></mdui-icon-cancel--outlined>`}</slot></mdui-button-icon></slot>`);
|
||||||
}
|
}
|
||||||
renderTogglePasswordButton(hasTogglePasswordButton) {
|
renderTogglePasswordButton(hasTogglePasswordButton) {
|
||||||
return when(hasTogglePasswordButton, () => html `<slot name="toggle-password-button" part="toggle-password-button" class="action" @click="${this.onTogglePassword}"><mdui-button-icon tabindex="-1">${this.isPasswordVisible
|
return when(hasTogglePasswordButton, () => html`<slot name="toggle-password-button" part="toggle-password-button" class="action" @click="${this.onTogglePassword}"><mdui-button-icon tabindex="-1">${this.isPasswordVisible
|
||||||
? html `<slot name="show-password-icon" part="show-password-icon">${this.showPasswordIcon
|
? html`<slot name="show-password-icon" part="show-password-icon">${this.showPasswordIcon
|
||||||
? html `<mdui-icon name="${this.showPasswordIcon}" class="i"></mdui-icon>`
|
? html`<mdui-icon name="${this.showPasswordIcon}" class="i"></mdui-icon>`
|
||||||
: html `<mdui-icon-visibility-off class="i"></mdui-icon-visibility-off>`}</slot>`
|
: html`<mdui-icon-visibility-off class="i"></mdui-icon-visibility-off>`}</slot>`
|
||||||
: html `<slot name="hide-password-icon" part="hide-password-icon">${this.hidePasswordIcon
|
: html`<slot name="hide-password-icon" part="hide-password-icon">${this.hidePasswordIcon
|
||||||
? html `<mdui-icon name="${this.hidePasswordIcon}" class="i"></mdui-icon>`
|
? html`<mdui-icon name="${this.hidePasswordIcon}" class="i"></mdui-icon>`
|
||||||
: html `<mdui-icon-visibility class="i"></mdui-icon-visibility>`}</slot>`}</mdui-button-icon></slot>`);
|
: html`<mdui-icon-visibility class="i"></mdui-icon-visibility>`}</slot>`}</mdui-button-icon></slot>`);
|
||||||
}
|
}
|
||||||
renderInput(hasInputSlot) {
|
renderInput(hasInputSlot) {
|
||||||
return html `<input ${ref(this.inputRef)} part="input" class="input ${classMap({ 'hide-input': hasInputSlot })}" type="${this.type === 'password' && this.isPasswordVisible
|
return html`<input ${ref(this.inputRef)} part="input" class="input ${classMap({ 'hide-input': hasInputSlot })}" type="${this.type === 'password' && this.isPasswordVisible
|
||||||
? 'text'
|
? 'text'
|
||||||
: this.type}" name="${ifDefined(this.name)}" .value="${live(this.value)}" placeholder="${ifDefined(!this.label || this.isFocusedStyle || this.hasValue
|
: this.type}" name="${ifDefined(this.name)}" .value="${live(this.value)}" placeholder="${ifDefined(!this.label || this.isFocusedStyle || this.hasValue
|
||||||
? this.placeholder
|
? this.placeholder
|
||||||
: undefined)}" ?readonly="${this.readonly}" ?disabled="${this.disabled}" ?required="${this.required}" minlength="${ifDefined(this.minlength)}" maxlength="${ifDefined(this.maxlength)}" min="${ifDefined(this.min)}" max="${ifDefined(this.max)}" step="${ifDefined(this.step)}" autocapitalize="${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)}" autocomplete="${this.autocomplete}" autocorrect="${ifDefined(this.type === 'password' ? 'off' : this.autocorrect)}" spellcheck="${ifDefined(this.spellcheck)}" pattern="${ifDefined(this.pattern)}" enterkeyhint="${ifDefined(this.enterkeyhint)}" inputmode="${ifDefined(this.inputmode)}" @change="${this.onChange}" @input="${this.onInput}" @invalid="${this.onInvalid}" @keydown="${this.onKeyDown}">`;
|
: undefined)}" ?readonly="${this.readonly}" ?disabled="${this.disabled}" ?required="${this.required}" minlength="${ifDefined(this.minlength)}" maxlength="${ifDefined(this.maxlength)}" min="${ifDefined(this.min)}" max="${ifDefined(this.max)}" step="${ifDefined(this.step)}" autocapitalize="${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)}" autocomplete="${this.autocomplete}" autocorrect="${ifDefined(this.type === 'password' ? 'off' : this.autocorrect)}" spellcheck="${ifDefined(this.spellcheck)}" pattern="${ifDefined(this.pattern)}" enterkeyhint="${ifDefined(this.enterkeyhint)}" inputmode="${ifDefined(this.inputmode)}" @change="${this.onChange}" @input="${this.onInput}" @invalid="${this.onInvalid}" @keydown="${this.onKeyDown}">`;
|
||||||
}
|
}
|
||||||
renderTextArea(hasInputSlot) {
|
renderTextArea(hasInputSlot) {
|
||||||
return html `<textarea ${ref(this.inputRef)} part="input" class="input ${classMap({ 'hide-input': hasInputSlot })}" name="${ifDefined(this.name)}" .value="${live(this.value)}" placeholder="${ifDefined(!this.label || this.isFocusedStyle || this.hasValue
|
return html`<textarea ${ref(this.inputRef)} part="input" class="input ${classMap({ 'hide-input': hasInputSlot })}" name="${ifDefined(this.name)}" .value="${live(this.value)}" placeholder="${ifDefined(!this.label || this.isFocusedStyle || this.hasValue
|
||||||
|
? this.placeholder
|
||||||
|
: undefined)}" ?readonly="${this.readonly}" ?disabled="${this.disabled}" ?required="${this.required}" minlength="${ifDefined(this.minlength)}" maxlength="${ifDefined(this.maxlength)}" rows="${this.rows ?? 1}" autocapitalize="${ifDefined(this.autocapitalize)}" autocorrect="${ifDefined(this.autocorrect)}" spellcheck="${ifDefined(this.spellcheck)}" enterkeyhint="${ifDefined(this.enterkeyhint)}" inputmode="${ifDefined(this.inputmode)}" @change="${this.onChange}" @input="${this.onInput}" @invalid="${this.onInvalid}" @keydown="${this.onKeyDown}" @keyup="${this.onTextAreaKeyUp}"></textarea>`;
|
||||||
|
}
|
||||||
|
renderPatchedTextArea(hasInputSlot) {
|
||||||
|
return html`<mdui-patched-textarea ${ref(this.inputRef)} part="input" class="input ${classMap({ 'hide-input': hasInputSlot })}" name="${ifDefined(this.name)}" .value="${live(this.value)}" placeholder="${ifDefined(!this.label || this.isFocusedStyle || this.hasValue
|
||||||
? this.placeholder
|
? this.placeholder
|
||||||
: undefined)}" ?readonly="${this.readonly}" ?disabled="${this.disabled}" ?required="${this.required}" minlength="${ifDefined(this.minlength)}" maxlength="${ifDefined(this.maxlength)}" rows="${this.rows ?? 1}" autocapitalize="${ifDefined(this.autocapitalize)}" autocorrect="${ifDefined(this.autocorrect)}" spellcheck="${ifDefined(this.spellcheck)}" enterkeyhint="${ifDefined(this.enterkeyhint)}" inputmode="${ifDefined(this.inputmode)}" @change="${this.onChange}" @input="${this.onInput}" @invalid="${this.onInvalid}" @keydown="${this.onKeyDown}" @keyup="${this.onTextAreaKeyUp}"></textarea>`;
|
: undefined)}" ?readonly="${this.readonly}" ?disabled="${this.disabled}" ?required="${this.required}" minlength="${ifDefined(this.minlength)}" maxlength="${ifDefined(this.maxlength)}" rows="${this.rows ?? 1}" autocapitalize="${ifDefined(this.autocapitalize)}" autocorrect="${ifDefined(this.autocorrect)}" spellcheck="${ifDefined(this.spellcheck)}" enterkeyhint="${ifDefined(this.enterkeyhint)}" inputmode="${ifDefined(this.inputmode)}" @change="${this.onChange}" @input="${this.onInput}" @invalid="${this.onInvalid}" @keydown="${this.onKeyDown}" @keyup="${this.onTextAreaKeyUp}"></textarea>`;
|
||||||
}
|
}
|
||||||
@@ -534,15 +545,15 @@ let TextField = class TextField extends FocusableMixin(MduiElement) {
|
|||||||
*/
|
*/
|
||||||
renderHelper(hasError, hasHelper) {
|
renderHelper(hasError, hasHelper) {
|
||||||
return hasError
|
return hasError
|
||||||
? html `<div part="error" class="error">${this.error || this.inputRef.value.validationMessage}</div>`
|
? html`<div part="error" class="error">${this.error || this.inputRef.value.validationMessage}</div>`
|
||||||
: hasHelper
|
: hasHelper
|
||||||
? html `<slot name="helper" part="helper" class="helper">${this.helper}</slot>`
|
? html`<slot name="helper" part="helper" class="helper">${this.helper}</slot>`
|
||||||
: // 右边有 counter,需要占位
|
: // 右边有 counter,需要占位
|
||||||
html `<span></span>`;
|
html`<span></span>`;
|
||||||
}
|
}
|
||||||
renderCounter(hasCounter) {
|
renderCounter(hasCounter) {
|
||||||
return hasCounter
|
return hasCounter
|
||||||
? html `<div part="counter" class="counter">${this.value.length}/${this.maxlength}</div>`
|
? html`<div part="counter" class="counter">${this.value.length}/${this.maxlength}</div>`
|
||||||
: nothingTemplate;
|
: nothingTemplate;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lingchair",
|
"name": "lingchair",
|
||||||
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./mdui_patched",
|
"./mdui_patched",
|
||||||
@@ -21,5 +22,10 @@
|
|||||||
"file-type": "21.0.0",
|
"file-type": "21.0.0",
|
||||||
"lingchair-internal-shared": "*",
|
"lingchair-internal-shared": "*",
|
||||||
"socket.io": "4.8.1"
|
"socket.io": "4.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/express-fileupload": "^1.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
readme.md
48
readme.md
@@ -6,31 +6,27 @@
|
|||||||
|
|
||||||
铃之椅, 一个普通的即时通讯项目——简单, 轻量, 纯粹, 时而天真
|
铃之椅, 一个普通的即时通讯项目——简单, 轻量, 纯粹, 时而天真
|
||||||
|
|
||||||
*目前还没有发布正式版本, 仍在积极开发中*
|
***仍在开发阶段, 随时都可能有破坏性变更!***
|
||||||
|
|
||||||
项目代号: TheWhiteSilk
|
### 目前的功能
|
||||||
|
|
||||||
### 基本功能
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>客户端</summary>
|
<summary>新客户端</summary>
|
||||||
|
|
||||||
|
*: 重构中
|
||||||
|
|
||||||
- 消息
|
- 消息
|
||||||
- [x] 收发消息
|
- [ ] *收发消息
|
||||||
- [x] 富文本 (based on Marked)
|
- [x] 富文本
|
||||||
- [x] 图片
|
|
||||||
- [x] 视频
|
|
||||||
- [x] 文件
|
|
||||||
- [ ] 测试其他 Markdown 语法的可用性
|
|
||||||
- [ ] 撤回消息
|
- [ ] 撤回消息
|
||||||
- [ ] 修改消息
|
- [ ] 修改消息
|
||||||
|
|
||||||
- 对话
|
- 对话
|
||||||
- [x] 最近对话
|
- [x] 最近对话
|
||||||
- [x] 添加对话
|
- [x] 添加收藏对话
|
||||||
- [x] 添加用户
|
- [x] 添加用户
|
||||||
- [x] 添加群组
|
- [x] 添加群组
|
||||||
- [ ] 群组管理
|
- [ ] *群组管理
|
||||||
|
|
||||||
- 帐号
|
- 帐号
|
||||||
- [x] 登录注册
|
- [x] 登录注册
|
||||||
@@ -64,46 +60,48 @@
|
|||||||
- [x] 登录注册
|
- [x] 登录注册
|
||||||
- [x] 资料编辑
|
- [x] 资料编辑
|
||||||
- [ ] 帐号管理
|
- [ ] 帐号管理
|
||||||
- [ ] 重设密码
|
- [x] 重设密码 (不够好!)
|
||||||
- [ ] 绑定邮箱
|
- [ ] 绑定邮箱
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### 快速上手
|
### 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://codeberg.org/CrescentLeaf/LingChair
|
git clone https://codeberg.org/CrescentLeaf/LingChair
|
||||||
cd LingChair
|
cd LingChair
|
||||||
# 编译前端
|
npm run install-dependencies
|
||||||
deno task build
|
npm run build-client
|
||||||
# 运行服务
|
npm run server
|
||||||
deno task server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 配置
|
#### 配置
|
||||||
|
|
||||||
[thewhitesilk_config.json 是什么?](./server/config.ts)
|
thewhitesilk_config.json [请见此处](./server/config.ts)
|
||||||
|
|
||||||
### 使用的项目 / 技术栈
|
### 使用到的项目 / 库
|
||||||
|
|
||||||
本项目由 Deno 强力驱动
|
由于 Deno 存在的严重问题, 已重新迁移到 Node.js
|
||||||
|
|
||||||
*当然, 由于没有使用 Deno Api, 只有 Node Api, 因此理论上 Node.js 也能运行, 但需要另外安装依赖*
|
- 客户端协议
|
||||||
|
- crypto-browserify
|
||||||
|
|
||||||
- 前端
|
- 前端
|
||||||
- 编译
|
- 编译
|
||||||
- vite
|
- vite
|
||||||
- vite-plugin-babel
|
|
||||||
- react
|
- react
|
||||||
- socket.io-client
|
- socket.io-client
|
||||||
- mdui
|
- mdui
|
||||||
- split.js
|
- split.js
|
||||||
- react-json-view
|
- ua-parser-js
|
||||||
|
- pinch-zoom
|
||||||
|
- use-context-selector
|
||||||
- dompurify
|
- dompurify
|
||||||
- marked
|
- marked
|
||||||
|
|
||||||
- 后端
|
- 后端
|
||||||
- express
|
- express
|
||||||
|
- express-fileupload
|
||||||
- socket.io
|
- socket.io
|
||||||
- chalk
|
- chalk
|
||||||
- file-type
|
- file-type
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export default class ChatApi extends BaseApi {
|
|||||||
code: 400,
|
code: 400,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.text == '') return {
|
||||||
|
code: 400,
|
||||||
|
msg: "消息文本为空",
|
||||||
|
}
|
||||||
|
|
||||||
const token = TokenManager.decode(args.token as string)
|
const token = TokenManager.decode(args.token as string)
|
||||||
if (!this.checkToken(token, deviceId)) return {
|
if (!this.checkToken(token, deviceId)) return {
|
||||||
code: 401,
|
code: 401,
|
||||||
@@ -87,7 +92,11 @@ export default class ChatApi extends BaseApi {
|
|||||||
* @param page 頁面
|
* @param page 頁面
|
||||||
*/
|
*/
|
||||||
this.registerEvent("Chat.getMessageHistory", (args, { deviceId }) => {
|
this.registerEvent("Chat.getMessageHistory", (args, { deviceId }) => {
|
||||||
if (this.checkArgsMissing(args, ['token', 'target', 'page'])) return {
|
if (this.checkArgsMissing(args, ['token', 'target'])) return {
|
||||||
|
msg: "参数缺失",
|
||||||
|
code: 400,
|
||||||
|
}
|
||||||
|
if (args.page == null && args.offset == null) return {
|
||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
}
|
}
|
||||||
@@ -112,49 +121,10 @@ export default class ChatApi extends BaseApi {
|
|||||||
code: 200,
|
code: 200,
|
||||||
msg: "成功",
|
msg: "成功",
|
||||||
data: {
|
data: {
|
||||||
messages: MessagesManager.getInstanceForChat(chat).getMessagesWithPage(15, args.page as number),
|
messages: MessagesManager.getInstanceForChat(chat)[args.page ? 'getMessagesWithPage' : 'getMessagesWithOffset'](args.limit as number | undefined, (args.page ? args.page : args.offset) as number),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
/**
|
|
||||||
* 上傳文件
|
|
||||||
* @param token 令牌
|
|
||||||
* @param target 目標對話
|
|
||||||
* @param file_name 文件名稱
|
|
||||||
* @param data 文件二進制數據
|
|
||||||
*/
|
|
||||||
/* this.registerEvent("Chat.uploadFile", async (args, { deviceId }) => {
|
|
||||||
if (this.checkArgsMissing(args, ['token', 'target', 'data', 'file_name'])) return {
|
|
||||||
msg: "参数缺失",
|
|
||||||
code: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = TokenManager.decode(args.token as string)
|
|
||||||
if (!this.checkToken(token, deviceId)) return {
|
|
||||||
code: 401,
|
|
||||||
msg: "令牌无效",
|
|
||||||
}
|
|
||||||
|
|
||||||
const chat = Chat.findById(args.target as string)
|
|
||||||
if (chat == null) return {
|
|
||||||
code: 404,
|
|
||||||
msg: "对话不存在",
|
|
||||||
}
|
|
||||||
if (!UserChatLinker.checkUserIsLinkedToChat(token.author, chat!.bean.id)) return {
|
|
||||||
code: 403,
|
|
||||||
msg: "用户无权访问此对话",
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await FileManager.uploadFile(args.file_name as string, args.data as Buffer<ArrayBufferLike>, args.target as string)
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: 200,
|
|
||||||
msg: "成功",
|
|
||||||
data: {
|
|
||||||
file_hash: file.getHash()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}) */
|
|
||||||
/**
|
/**
|
||||||
* ======================================================
|
* ======================================================
|
||||||
* 对话成员
|
* 对话成员
|
||||||
@@ -388,7 +358,7 @@ export default class ChatApi extends BaseApi {
|
|||||||
* @param token 令牌
|
* @param token 令牌
|
||||||
* @param target 目標用户
|
* @param target 目標用户
|
||||||
*/
|
*/
|
||||||
this.registerEvent("Chat.getIdForPrivate", (args, { deviceId }) => {
|
this.registerEvent("Chat.getOrCreatePrivateChat", (args, { deviceId }) => {
|
||||||
if (this.checkArgsMissing(args, ['token', 'target'])) return {
|
if (this.checkArgsMissing(args, ['token', 'target'])) return {
|
||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
@@ -413,10 +383,6 @@ export default class ChatApi extends BaseApi {
|
|||||||
code: 200,
|
code: 200,
|
||||||
msg: '成功',
|
msg: '成功',
|
||||||
data: {
|
data: {
|
||||||
// TODO: 移除这个, 将本方法重命名为 getOrCreatePrivateChat
|
|
||||||
// 并重构原 Web 客户端所引用的内容
|
|
||||||
chat_id: chat.bean.id,
|
|
||||||
|
|
||||||
id: chat.bean.id,
|
id: chat.bean.id,
|
||||||
name: chat.bean.name,
|
name: chat.bean.name,
|
||||||
type: chat.bean.type,
|
type: chat.bean.type,
|
||||||
@@ -459,7 +425,7 @@ export default class ChatApi extends BaseApi {
|
|||||||
chat.addAdmin(user.bean.id, [
|
chat.addAdmin(user.bean.id, [
|
||||||
AdminPermissions.OWNER,
|
AdminPermissions.OWNER,
|
||||||
])
|
])
|
||||||
user.addContact(chat.bean.id)
|
user.addFavouriteChat(chat.bean.id)
|
||||||
MessagesManager.getInstanceForChat(chat).addSystemMessage("群组已创建")
|
MessagesManager.getInstanceForChat(chat).addSystemMessage("群组已创建")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -570,10 +536,6 @@ export default class ChatApi extends BaseApi {
|
|||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
}
|
}
|
||||||
/* if (!(args.avatar instanceof Buffer)) return {
|
|
||||||
msg: "参数不合法",
|
|
||||||
code: 400,
|
|
||||||
} */
|
|
||||||
const token = TokenManager.decode(args.token as string)
|
const token = TokenManager.decode(args.token as string)
|
||||||
|
|
||||||
const user = User.findById(token.author) as User
|
const user = User.findById(token.author) as User
|
||||||
@@ -587,9 +549,6 @@ export default class ChatApi extends BaseApi {
|
|||||||
if (chat.bean.type == 'group')
|
if (chat.bean.type == 'group')
|
||||||
if (chat.checkUserIsAdmin(user.bean.id)) {
|
if (chat.checkUserIsAdmin(user.bean.id)) {
|
||||||
chat.setAvatarFileHash(args.file_hash as string)
|
chat.setAvatarFileHash(args.file_hash as string)
|
||||||
/* const avatar: Buffer = args.avatar as Buffer
|
|
||||||
if (avatar)
|
|
||||||
chat.setAvatar(avatar) */
|
|
||||||
} else
|
} else
|
||||||
return {
|
return {
|
||||||
code: 403,
|
code: 403,
|
||||||
|
|||||||
@@ -140,14 +140,15 @@ export default class UserApi extends BaseApi {
|
|||||||
})
|
})
|
||||||
// 注冊
|
// 注冊
|
||||||
this.registerEvent("User.register", (args, { deviceId }) => {
|
this.registerEvent("User.register", (args, { deviceId }) => {
|
||||||
if (this.checkArgsMissing(args, ['nickname', 'password'])) return {
|
// 判断密码是否为空已经没有意义
|
||||||
|
// 因为空字符的哈希值不为空
|
||||||
|
// 后续会修缮关于注册的机制
|
||||||
|
// 比如限制 IP, 需要邮箱
|
||||||
|
// 虽然我知道这没有什么意义
|
||||||
|
if (this.checkArgsEmpty(args, ['nickname'])) return {
|
||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
}
|
}
|
||||||
if (this.checkArgsEmpty(args, ['nickname', 'password'])) return {
|
|
||||||
msg: "参数不得为空",
|
|
||||||
code: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
const username: string | null = args.username as string
|
const username: string | null = args.username as string
|
||||||
const nickname: string = args.nickname as string
|
const nickname: string = args.nickname as string
|
||||||
@@ -321,12 +322,12 @@ export default class UserApi extends BaseApi {
|
|||||||
|
|
||||||
const user = User.findById(token.author) as User
|
const user = User.findById(token.author) as User
|
||||||
const recentChats = user.getRecentChats()
|
const recentChats = user.getRecentChats()
|
||||||
const recentChatsList = []
|
const recentChatsList: any[] = []
|
||||||
for (const [chatId, content] of recentChats) {
|
for (const {chat_id, content} of recentChats) {
|
||||||
const chat = Chat.findById(chatId)
|
const chat = Chat.findById(chat_id)
|
||||||
recentChatsList.push({
|
recentChatsList.push({
|
||||||
content,
|
content,
|
||||||
id: chatId,
|
id: chat_id,
|
||||||
title: chat?.getTitle(user) || "未知",
|
title: chat?.getTitle(user) || "未知",
|
||||||
avatar_file_hash: chat?.getAvatarFileHash(user) ? chat?.getAvatarFileHash(user) : undefined
|
avatar_file_hash: chat?.getAvatarFileHash(user) ? chat?.getAvatarFileHash(user) : undefined
|
||||||
})
|
})
|
||||||
@@ -341,7 +342,7 @@ export default class UserApi extends BaseApi {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// 獲取聯絡人列表
|
// 獲取聯絡人列表
|
||||||
this.registerEvent("User.getMyContacts", (args, { deviceId }) => {
|
this.registerEvent("User.getMyFavouriteChats", (args, { deviceId }) => {
|
||||||
if (this.checkArgsMissing(args, ['token'])) return {
|
if (this.checkArgsMissing(args, ['token'])) return {
|
||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
@@ -354,14 +355,13 @@ export default class UserApi extends BaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = User.findById(token.author) as User
|
const user = User.findById(token.author) as User
|
||||||
const contacts = user.getContactsList()
|
const favourite_chats = user.getFavouriteChats()
|
||||||
contacts.push(ChatPrivate.getChatIdByUsersId(token.author, token.author))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
msg: "成功",
|
msg: "成功",
|
||||||
code: 200,
|
code: 200,
|
||||||
data: {
|
data: {
|
||||||
contacts_list: contacts.map((id) => {
|
favourite_chats: favourite_chats.map((id) => {
|
||||||
const chat = Chat.findById(id)
|
const chat = Chat.findById(id)
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -374,8 +374,8 @@ export default class UserApi extends BaseApi {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// 添加聯絡人
|
// 添加聯絡人
|
||||||
this.registerEvent("User.addContacts", (args, { deviceId }) => {
|
this.registerEvent("User.addFavouriteChats", (args, { deviceId }) => {
|
||||||
if (this.checkArgsMissing(args, ['token', 'targets'])) return {
|
if (this.checkArgsMissing(args, ['token', 'chat_ids'])) return {
|
||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
}
|
}
|
||||||
@@ -388,14 +388,14 @@ export default class UserApi extends BaseApi {
|
|||||||
|
|
||||||
let fail = 0
|
let fail = 0
|
||||||
const user = User.findById(token.author) as User
|
const user = User.findById(token.author) as User
|
||||||
for (const target of (args.targets as string[])) {
|
for (const target of (args.chat_ids as string[])) {
|
||||||
const chat = Chat.findById(target) || Chat.findByName(target)
|
const chat = Chat.findById(target) || Chat.findByName(target)
|
||||||
const targetUser = User.findByAccount(target) as User
|
const targetUser = User.findByAccount(target) as User
|
||||||
if (chat)
|
if (chat)
|
||||||
user!.addContact(chat.bean.id)
|
user!.addFavouriteChat(chat.bean.id)
|
||||||
else if (targetUser) {
|
else if (targetUser) {
|
||||||
const privChat = ChatPrivate.findOrCreateForPrivate(user, targetUser)
|
const privChat = ChatPrivate.findOrCreateForPrivate(user, targetUser)
|
||||||
user!.addContact(privChat.bean.id)
|
user!.addFavouriteChat(privChat.bean.id)
|
||||||
} else {
|
} else {
|
||||||
fail++
|
fail++
|
||||||
}
|
}
|
||||||
@@ -407,8 +407,8 @@ export default class UserApi extends BaseApi {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// 添加聯絡人
|
// 添加聯絡人
|
||||||
this.registerEvent("User.removeContacts", (args, { deviceId }) => {
|
this.registerEvent("User.removeFavouriteChats", (args, { deviceId }) => {
|
||||||
if (this.checkArgsMissing(args, ['token', 'targets'])) return {
|
if (this.checkArgsMissing(args, ['token', 'chat_ids'])) return {
|
||||||
msg: "参数缺失",
|
msg: "参数缺失",
|
||||||
code: 400,
|
code: 400,
|
||||||
}
|
}
|
||||||
@@ -420,7 +420,7 @@ export default class UserApi extends BaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = User.findById(token.author) as User
|
const user = User.findById(token.author) as User
|
||||||
user.removeContacts(args.targets as string[])
|
user.removeFavouriteChats(args.chat_ids as string[])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
msg: "成功",
|
msg: "成功",
|
||||||
|
|||||||
@@ -59,8 +59,13 @@ export default class Chat {
|
|||||||
return new Chat(beans[0])
|
return new Chat(beans[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话创建基本方法
|
||||||
|
* @param chatName 对话别名, 供查询
|
||||||
|
* @param type 对话类型
|
||||||
|
*/
|
||||||
static create(chatName: string | undefined, type: ChatType) {
|
static create(chatName: string | undefined, type: ChatType) {
|
||||||
if (this.findAllChatBeansByCondition('id = ?', chatName || null).length > 0)
|
if (this.findAllChatBeansByCondition('name = ?', chatName || null).length > 0)
|
||||||
throw new DataWrongError(`对话名称 ${chatName} 已被使用`)
|
throw new DataWrongError(`对话名称 ${chatName} 已被使用`)
|
||||||
const chat = new Chat(
|
const chat = new Chat(
|
||||||
Chat.findAllChatBeansByCondition(
|
Chat.findAllChatBeansByCondition(
|
||||||
@@ -123,6 +128,11 @@ export default class Chat {
|
|||||||
* ======================================================
|
* ======================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加加入请求
|
||||||
|
* @param userId
|
||||||
|
* @param reason
|
||||||
|
*/
|
||||||
addJoinRequest(userId: string, reason?: string) {
|
addJoinRequest(userId: string, reason?: string) {
|
||||||
if (this.findAllJoinRequestsByCondition('user_id = ?', userId).length == 0)
|
if (this.findAllJoinRequestsByCondition('user_id = ?', userId).length == 0)
|
||||||
Chat.database.prepare(`INSERT INTO ${this.getJoinRequestsTableName()} (
|
Chat.database.prepare(`INSERT INTO ${this.getJoinRequestsTableName()} (
|
||||||
@@ -149,6 +159,11 @@ export default class Chat {
|
|||||||
* ======================================================
|
* ======================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加对话管理员
|
||||||
|
* @param userId
|
||||||
|
* @param permission
|
||||||
|
*/
|
||||||
addAdmin(userId: string, permission: string[] | string) {
|
addAdmin(userId: string, permission: string[] | string) {
|
||||||
if (!this.checkUserIsAdmin(userId))
|
if (!this.checkUserIsAdmin(userId))
|
||||||
Chat.database.prepare(`INSERT INTO ${this.getAdminsTableName()} (
|
Chat.database.prepare(`INSERT INTO ${this.getAdminsTableName()} (
|
||||||
@@ -185,6 +200,9 @@ export default class Chat {
|
|||||||
* ======================================================
|
* ======================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对话成员
|
||||||
|
*/
|
||||||
getMembersList() {
|
getMembersList() {
|
||||||
return UserChatLinker.getChatMembers(this.bean.id)
|
return UserChatLinker.getChatMembers(this.bean.id)
|
||||||
}
|
}
|
||||||
@@ -201,6 +219,10 @@ export default class Chat {
|
|||||||
* ======================================================
|
* ======================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从**私聊**中获取对方用户
|
||||||
|
* @param userMySelf
|
||||||
|
*/
|
||||||
getAnotherUserForPrivate(userMySelf: User) {
|
getAnotherUserForPrivate(userMySelf: User) {
|
||||||
const members = this.getMembersList()
|
const members = this.getMembersList()
|
||||||
const user_a_id = members[0]
|
const user_a_id = members[0]
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class GroupSettings {
|
|||||||
this.settings = JSON.parse(chat.bean.settings)
|
this.settings = JSON.parse(chat.bean.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 覆盖群组设定
|
||||||
|
* @param bean 要覆盖的设定, 不需要覆盖的不需要填入
|
||||||
|
*/
|
||||||
update(bean: GroupSettingsBean) {
|
update(bean: GroupSettingsBean) {
|
||||||
const updateValue = (key: string) => {
|
const updateValue = (key: string) => {
|
||||||
if (key in bean)
|
if (key in bean)
|
||||||
@@ -26,6 +30,9 @@ class GroupSettings {
|
|||||||
|
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 应用更改
|
||||||
|
*/
|
||||||
apply() {
|
apply() {
|
||||||
this.chat.setAttr('settings', JSON.stringify(this.settings))
|
this.chat.setAttr('settings', JSON.stringify(this.settings))
|
||||||
}
|
}
|
||||||
@@ -36,10 +43,19 @@ export default class ChatGroup extends Chat {
|
|||||||
return new GroupSettings(this)
|
return new GroupSettings(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保是群组类型后, 转换成群组对话
|
||||||
|
* 唯一的作用可能是修改群组设定
|
||||||
|
* @param chat
|
||||||
|
*/
|
||||||
static fromChat(chat: Chat) {
|
static fromChat(chat: Chat) {
|
||||||
return new ChatGroup(chat.bean)
|
return new ChatGroup(chat.bean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的群组
|
||||||
|
* @param group_name 群组名称
|
||||||
|
*/
|
||||||
static createGroup(group_name?: string) {
|
static createGroup(group_name?: string) {
|
||||||
return this.create(group_name, 'group')
|
return this.create(group_name, 'group')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import Chat from "./Chat.ts"
|
|||||||
import User from "./User.ts"
|
import User from "./User.ts"
|
||||||
|
|
||||||
export default class ChatPrivate extends Chat {
|
export default class ChatPrivate extends Chat {
|
||||||
|
/**
|
||||||
|
* 确保是私聊类型后, 转换成私聊对话
|
||||||
|
* 实际上没啥用, 因为实例方法都在 Chat
|
||||||
|
* 未来可能会移除
|
||||||
|
* @param chat
|
||||||
|
*/
|
||||||
static fromChat(chat: Chat) {
|
static fromChat(chat: Chat) {
|
||||||
return new ChatPrivate(chat.bean)
|
return new ChatPrivate(chat.bean)
|
||||||
}
|
}
|
||||||
@@ -11,6 +17,11 @@ export default class ChatPrivate extends Chat {
|
|||||||
return 'priv_' + [userIdA, userIdB].sort().join('__').replaceAll('-', '_')
|
return 'priv_' + [userIdA, userIdB].sort().join('__').replaceAll('-', '_')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为两个用户创建对话 (无需注意顺序)
|
||||||
|
* @param userA
|
||||||
|
* @param userB
|
||||||
|
*/
|
||||||
static createForPrivate(userA: User, userB: User) {
|
static createForPrivate(userA: User, userB: User) {
|
||||||
const chat = this.create(undefined, 'private')
|
const chat = this.create(undefined, 'private')
|
||||||
chat.setAttr('id', this.getChatIdByUsersId(userA.bean.id, userB.bean.id))
|
chat.setAttr('id', this.getChatIdByUsersId(userA.bean.id, userB.bean.id))
|
||||||
@@ -19,11 +30,21 @@ export default class ChatPrivate extends Chat {
|
|||||||
userB.bean.id
|
userB.bean.id
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 寻找两个用户间的对话 (无需注意顺序)
|
||||||
|
* @param userA
|
||||||
|
* @param userB
|
||||||
|
*/
|
||||||
static findByUsersForPrivate(userA: User, userB: User) {
|
static findByUsersForPrivate(userA: User, userB: User) {
|
||||||
const chat = this.findById(this.getChatIdByUsersId(userA.bean.id, userB.bean.id))
|
const chat = this.findById(this.getChatIdByUsersId(userA.bean.id, userB.bean.id))
|
||||||
if (chat)
|
if (chat)
|
||||||
return this.fromChat(chat as Chat)
|
return this.fromChat(chat as Chat)
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 寻找两个用户间的对话, 若无则创建 (无需注意顺序)
|
||||||
|
* @param userA
|
||||||
|
* @param userB
|
||||||
|
*/
|
||||||
static findOrCreateForPrivate(userA: User, userB: User) {
|
static findOrCreateForPrivate(userA: User, userB: User) {
|
||||||
let a = this.findByUsersForPrivate(userA, userB)
|
let a = this.findByUsersForPrivate(userA, userB)
|
||||||
if (a == null) {
|
if (a == null) {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class File {
|
|||||||
getName() {
|
getName() {
|
||||||
return this.bean.name
|
return this.bean.name
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 获取文件的相对路径
|
||||||
|
*/
|
||||||
getFilePath() {
|
getFilePath() {
|
||||||
const hash = this.bean.hash
|
const hash = this.bean.hash
|
||||||
return path.join(
|
return path.join(
|
||||||
@@ -90,6 +93,12 @@ export default class FileManager {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件 (与 HTTP API 对接)
|
||||||
|
* @param fileName 文件名
|
||||||
|
* @param data 文件二进制数据
|
||||||
|
* @param chatId 所属的对话
|
||||||
|
*/
|
||||||
static async uploadFile(fileName: string, data: Buffer, chatId?: string) {
|
static async uploadFile(fileName: string, data: Buffer, chatId?: string) {
|
||||||
const hash = crypto.createHash('sha256').update(data).digest('hex')
|
const hash = crypto.createHash('sha256').update(data).digest('hex')
|
||||||
const file = FileManager.findByHash(hash)
|
const file = FileManager.findByHash(hash)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import chalk from "chalk"
|
|||||||
import config from "../config.ts"
|
import config from "../config.ts"
|
||||||
import Chat from "./Chat.ts"
|
import Chat from "./Chat.ts"
|
||||||
import MessageBean from "./MessageBean.ts"
|
import MessageBean from "./MessageBean.ts"
|
||||||
|
import { PageFetchMaxLimit } from "lingchair-internal-shared"
|
||||||
|
|
||||||
export default class MessagesManager {
|
export default class MessagesManager {
|
||||||
static database: DatabaseSync = this.init()
|
static database: DatabaseSync = this.init()
|
||||||
@@ -15,6 +16,10 @@ export default class MessagesManager {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为对话获取实例
|
||||||
|
* @param chat 对话
|
||||||
|
*/
|
||||||
static getInstanceForChat(chat: Chat) {
|
static getInstanceForChat(chat: Chat) {
|
||||||
return new MessagesManager(chat)
|
return new MessagesManager(chat)
|
||||||
}
|
}
|
||||||
@@ -35,6 +40,9 @@ export default class MessagesManager {
|
|||||||
protected getTableName() {
|
protected getTableName() {
|
||||||
return `messages_${this.chat.bean.id}`.replaceAll('-', '_')
|
return `messages_${this.chat.bean.id}`.replaceAll('-', '_')
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 添加一条消息
|
||||||
|
*/
|
||||||
addMessage({
|
addMessage({
|
||||||
text,
|
text,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -54,19 +62,58 @@ export default class MessagesManager {
|
|||||||
time || Date.now()
|
time || Date.now()
|
||||||
).lastInsertRowid
|
).lastInsertRowid
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 添加一条无用户信息的系统消息
|
||||||
|
*/
|
||||||
addSystemMessage(text: string) {
|
addSystemMessage(text: string) {
|
||||||
this.addMessage({
|
this.addMessage({
|
||||||
text
|
text
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
getMessages(limit: number = 15, offset: number = 0) {
|
/**
|
||||||
const ls = MessagesManager.database.prepare(`SELECT * FROM ${this.getTableName()} ORDER BY id DESC LIMIT ? OFFSET ?;`).all(limit, offset) as unknown as MessageBean[]
|
* 从最新消息开始偏移某些量向**前**获取 n 条消息 (顺序: 从新到旧)
|
||||||
|
* @param limit 获取消息的数量
|
||||||
|
* @param offset 偏移量
|
||||||
|
*/
|
||||||
|
getMessagesWithOffset(limit: number | undefined | null, offset: number = 0): MessageBean[] {
|
||||||
|
const ls = MessagesManager.database.prepare(`SELECT * FROM ${this.getTableName()} ORDER BY id DESC LIMIT ? OFFSET ?;`).all(limit || PageFetchMaxLimit, offset) as unknown as MessageBean[]
|
||||||
return ls.map((v) => ({
|
return ls.map((v) => ({
|
||||||
...v,
|
...v,
|
||||||
chat_id: this.chat.bean.id,
|
chat_id: this.chat.bean.id,
|
||||||
}))
|
})).reverse()
|
||||||
}
|
}
|
||||||
getMessagesWithPage(limit: number = 15, page: number = 0) {
|
/**
|
||||||
return this.getMessages(limit, limit * page)
|
* 从最新消息开始偏移某些量向**前**获取第 n 页消息 (顺序: 从新到旧)
|
||||||
|
* @param limit 获取消息的数量
|
||||||
|
* @param page 页数
|
||||||
|
*/
|
||||||
|
getMessagesWithPage(limit: number | undefined | null, page: number = 0) {
|
||||||
|
return this.getMessagesWithOffset(limit, (limit || PageFetchMaxLimit) * page)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取最新的消息的 ID
|
||||||
|
*/
|
||||||
|
getNewestMessageId() {
|
||||||
|
return MessagesManager.database.prepare(`SELECT id FROM ${this.getTableName()} ORDER BY id DESC LIMIT 1;`).all()[0].id as number | undefined
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 从某消息开始获取包括其在内往**前**的 n 条消息 (顺序: 从新到旧)
|
||||||
|
* @param limit 获取消息的数量
|
||||||
|
* @param msg_id 从哪条开始? (-1 = 最新)
|
||||||
|
*/
|
||||||
|
getMessagesEndWith(limit: number | undefined | null, msg_id: number) {
|
||||||
|
const newestMessageId = this.getNewestMessageId()
|
||||||
|
const offset = (msg_id == -1 || newestMessageId == null) ? 0 : (newestMessageId - msg_id)
|
||||||
|
return this.getMessagesWithOffset(limit, offset)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 从某消息开始获取包括其在内往**后**的 n 条消息 (顺序: 从新到旧)
|
||||||
|
* @param limit 获取消息的数量
|
||||||
|
* @param msg_id 从哪条开始? (-1 = 最新)
|
||||||
|
*/
|
||||||
|
getMessagesBeginWith(limit: number | undefined | null, msg_id: number) {
|
||||||
|
const newestMessageId = this.getNewestMessageId()
|
||||||
|
const offset = (msg_id == -1 || newestMessageId == null) ? 0 : (newestMessageId - msg_id)
|
||||||
|
return this.getMessagesWithOffset(limit, offset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
server/data/RecentChatBean.ts
Normal file
10
server/data/RecentChatBean.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default class RecentChatBean {
|
||||||
|
declare count: number
|
||||||
|
/** 最近对话所关联的用户 */
|
||||||
|
declare user_id: string
|
||||||
|
declare chat_id: string
|
||||||
|
declare content: string
|
||||||
|
declare updated_time: number
|
||||||
|
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
@@ -11,11 +11,10 @@ import UserBean from './UserBean.ts'
|
|||||||
import FileManager from './FileManager.ts'
|
import FileManager from './FileManager.ts'
|
||||||
import { SQLInputValue } from "node:sqlite"
|
import { SQLInputValue } from "node:sqlite"
|
||||||
import ChatPrivate from "./ChatPrivate.ts"
|
import ChatPrivate from "./ChatPrivate.ts"
|
||||||
import Chat from "./Chat.ts"
|
|
||||||
import ChatBean from "./ChatBean.ts"
|
|
||||||
import MapJson from "../MapJson.ts"
|
|
||||||
import DataWrongError from '../api/DataWrongError.ts'
|
import DataWrongError from '../api/DataWrongError.ts'
|
||||||
import UserChatLinker from "./UserChatLinker.ts";
|
import UserChatLinker from "./UserChatLinker.ts"
|
||||||
|
import UserFavouriteChatLinker from "./UserFavouriteChatLinker.ts"
|
||||||
|
import UserRecentChatLinker from "./UserRecentChatLinker.ts"
|
||||||
|
|
||||||
type UserBeanKey = keyof UserBean
|
type UserBeanKey = keyof UserBean
|
||||||
|
|
||||||
@@ -38,8 +37,6 @@ export default class User {
|
|||||||
/* 用户名 */ username TEXT,
|
/* 用户名 */ username TEXT,
|
||||||
/* 昵称 */ nickname TEXT NOT NULL,
|
/* 昵称 */ nickname TEXT NOT NULL,
|
||||||
/* 头像, 可选 */ avatar_file_hash TEXT,
|
/* 头像, 可选 */ avatar_file_hash TEXT,
|
||||||
/* 对话列表 */ contacts_list TEXT NOT NULL,
|
|
||||||
/* 最近对话 */ recent_chats TEXT NOT NULL,
|
|
||||||
/* 设置 */ settings TEXT NOT NULL
|
/* 设置 */ settings TEXT NOT NULL
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
@@ -49,6 +46,13 @@ export default class User {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户名是否存在, 不存在则创建用户, 否则报错
|
||||||
|
* @param userName
|
||||||
|
* @param password
|
||||||
|
* @param nickName
|
||||||
|
* @param avatar
|
||||||
|
*/
|
||||||
static create(userName: string | null, password: string, nickName: string, avatar: Buffer | null) {
|
static create(userName: string | null, password: string, nickName: string, avatar: Buffer | null) {
|
||||||
if (userName && User.findAllBeansByCondition('username = ?', userName).length > 0)
|
if (userName && User.findAllBeansByCondition('username = ?', userName).length > 0)
|
||||||
throw new DataWrongError(`用户名 ${userName} 已存在`)
|
throw new DataWrongError(`用户名 ${userName} 已存在`)
|
||||||
@@ -62,18 +66,14 @@ export default class User {
|
|||||||
username,
|
username,
|
||||||
nickname,
|
nickname,
|
||||||
avatar_file_hash,
|
avatar_file_hash,
|
||||||
contacts_list,
|
|
||||||
recent_chats,
|
|
||||||
settings
|
settings
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(
|
) VALUES (?, ?, ?, ?, ?, ?, ?);`).run(
|
||||||
crypto.randomUUID(),
|
crypto.randomUUID(),
|
||||||
password,
|
password,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
userName,
|
userName,
|
||||||
nickName,
|
nickName,
|
||||||
null,
|
null,
|
||||||
'[]',
|
|
||||||
JSON.stringify(new Map(), MapJson.replacer),
|
|
||||||
"{}"
|
"{}"
|
||||||
).lastInsertRowid
|
).lastInsertRowid
|
||||||
)[0]
|
)[0]
|
||||||
@@ -102,6 +102,10 @@ export default class User {
|
|||||||
console.error(chalk.red(`警告: 查询 username = ${userName} 时, 查询到多个相同用户名的用户`))
|
console.error(chalk.red(`警告: 查询 username = ${userName} 时, 查询到多个相同用户名的用户`))
|
||||||
return new User(beans[0])
|
return new User(beans[0])
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 通过用户名或 ID 获取某个用户, 用户名优先
|
||||||
|
* @param account 用户名或用户 ID
|
||||||
|
*/
|
||||||
static findByAccount(account: string) {
|
static findByAccount(account: string) {
|
||||||
return User.findByUserName(account) || User.findById(account)
|
return User.findByUserName(account) || User.findById(account)
|
||||||
}
|
}
|
||||||
@@ -125,47 +129,35 @@ export default class User {
|
|||||||
this.setAttr("username", userName)
|
this.setAttr("username", userName)
|
||||||
}
|
}
|
||||||
updateRecentChat(chatId: string, content: string) {
|
updateRecentChat(chatId: string, content: string) {
|
||||||
const map = JSON.parse(this.bean.recent_chats, MapJson.reviver) as Map<string, string>
|
UserRecentChatLinker.updateOrAddRecentChat(this.bean.id, chatId, content)
|
||||||
map.delete(chatId)
|
|
||||||
map.set(chatId, content)
|
|
||||||
this.setAttr("recent_chats", JSON.stringify(map, MapJson.replacer))
|
|
||||||
}
|
}
|
||||||
getRecentChats(): Map<string, string> {
|
getRecentChats() {
|
||||||
try {
|
return UserRecentChatLinker.getUserRecentChatBeans(this.bean.id)
|
||||||
return JSON.parse(this.bean.recent_chats, MapJson.reviver)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(chalk.yellow(`警告: 最近对话列表解析失敗: ${(e as Error).message}`))
|
|
||||||
return new Map()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFavouriteChats() {
|
||||||
|
return UserFavouriteChatLinker.getUserFavouriteChats(this.bean.id)
|
||||||
}
|
}
|
||||||
addContact(chatId: string) {
|
addFavouriteChats(chatIds: string[]) {
|
||||||
const ls = this.getContactsList()
|
chatIds.forEach((v) => UserFavouriteChatLinker.linkUserAndChat(this.bean.id, v))
|
||||||
if (ls.indexOf(chatId) != -1 || ChatPrivate.getChatIdByUsersId(this.bean.id, this.bean.id) == chatId) return
|
|
||||||
ls.push(chatId)
|
|
||||||
this.setAttr("contacts_list", JSON.stringify(ls))
|
|
||||||
}
|
}
|
||||||
removeContacts(contacts: string[]) {
|
removeFavouriteChats(chatIds: string[]) {
|
||||||
const ls = this.getContactsList().filter((v) => !contacts.includes(v))
|
chatIds.forEach((v) => UserFavouriteChatLinker.unlinkUserAndChat(this.bean.id, v))
|
||||||
this.setAttr("contacts_list", JSON.stringify(ls))
|
|
||||||
}
|
|
||||||
getContactsList() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(this.bean.contacts_list) as string[]
|
|
||||||
} catch (e) {
|
|
||||||
console.log(chalk.yellow(`警告: 所有对话解析失败: ${(e as Error).message}`))
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
addFavouriteChat(chatId: string) {
|
||||||
|
this.addFavouriteChats([chatId])
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllChatsList() {
|
getAllChatsList() {
|
||||||
return UserChatLinker.getUserChats(this.bean.id)
|
return UserChatLinker.getUserChats(this.bean.id)
|
||||||
}
|
}
|
||||||
getNickName(): string {
|
getNickName() {
|
||||||
return this.bean.nickname
|
return this.bean.nickname
|
||||||
}
|
}
|
||||||
setNickName(nickName: string) {
|
setNickName(nickName: string) {
|
||||||
this.setAttr("nickname", nickName)
|
this.setAttr("nickname", nickName)
|
||||||
}
|
}
|
||||||
getPassword(): string {
|
getPassword() {
|
||||||
return this.bean.password
|
return this.bean.password
|
||||||
}
|
}
|
||||||
setPassword(password: string) {
|
setPassword(password: string) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default class UserBean {
|
|||||||
declare registered_time: number
|
declare registered_time: number
|
||||||
declare nickname: string
|
declare nickname: string
|
||||||
declare avatar_file_hash?: string
|
declare avatar_file_hash?: string
|
||||||
declare contacts_list: string
|
declare favourite_chats: string
|
||||||
declare recent_chats: string
|
declare recent_chats: string
|
||||||
declare settings: string
|
declare settings: string
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export default class UserChatLinker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 對用戶和對話建立關聯
|
* 若用户和对话未关联, 则进行关联
|
||||||
* 自動檢測是否已關聯, 保證不會重複
|
|
||||||
*/
|
*/
|
||||||
static linkUserAndChat(userId: string, chatId: string) {
|
static linkUserAndChat(userId: string, chatId: string) {
|
||||||
if (!this.checkUserIsLinkedToChat(userId, chatId))
|
if (!this.checkUserIsLinkedToChat(userId, chatId))
|
||||||
@@ -34,15 +33,27 @@ export default class UserChatLinker {
|
|||||||
chatId
|
chatId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 解除用户和对话的关联
|
||||||
|
*/
|
||||||
static unlinkUserAndChat(userId: string, chatId: string) {
|
static unlinkUserAndChat(userId: string, chatId: string) {
|
||||||
this.database.prepare(`DELETE FROM UserChatLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
|
this.database.prepare(`DELETE FROM UserChatLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 检测用户和对话的关联
|
||||||
|
*/
|
||||||
static checkUserIsLinkedToChat(userId: string, chatId: string) {
|
static checkUserIsLinkedToChat(userId: string, chatId: string) {
|
||||||
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
|
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 获取用户所有关联的对话
|
||||||
|
*/
|
||||||
static getUserChats(userId: string) {
|
static getUserChats(userId: string) {
|
||||||
return this.findAllByCondition('user_id = ?', userId).map((v) => v.chat_id) as string[]
|
return this.findAllByCondition('user_id = ?', userId).map((v) => v.chat_id) as string[]
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 获取对话所有关联的用户
|
||||||
|
*/
|
||||||
static getChatMembers(chatId: string) {
|
static getChatMembers(chatId: string) {
|
||||||
return this.findAllByCondition('chat_id = ?', chatId).map((v) => v.user_id) as string[]
|
return this.findAllByCondition('chat_id = ?', chatId).map((v) => v.user_id) as string[]
|
||||||
}
|
}
|
||||||
|
|||||||
57
server/data/UserFavouriteChatLinker.ts
Normal file
57
server/data/UserFavouriteChatLinker.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { DatabaseSync } from "node:sqlite"
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import config from "../config.ts"
|
||||||
|
import { SQLInputValue } from "node:sqlite"
|
||||||
|
|
||||||
|
export default class UserFavouriteChatLinker {
|
||||||
|
static database: DatabaseSync = this.init()
|
||||||
|
|
||||||
|
private static init(): DatabaseSync {
|
||||||
|
const db: DatabaseSync = new DatabaseSync(path.join(config.data_path, 'UserFavouriteChatLinker.db'))
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS UserFavouriteChatLinker (
|
||||||
|
/* 序号 */ count INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
/* 用戶 ID */ user_id TEXT NOT NULL,
|
||||||
|
/* Chat ID */ chat_id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_id ON UserFavouriteChatLinker(user_id);`)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 若用户和对话未关联, 则进行关联
|
||||||
|
*/
|
||||||
|
static linkUserAndChat(userId: string, chatId: string) {
|
||||||
|
if (!this.checkUserIsLinkedToChat(userId, chatId))
|
||||||
|
this.database.prepare(`INSERT INTO UserFavouriteChatLinker (
|
||||||
|
user_id,
|
||||||
|
chat_id
|
||||||
|
) VALUES (?, ?);`).run(
|
||||||
|
userId,
|
||||||
|
chatId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 解除用户和对话的关联
|
||||||
|
*/
|
||||||
|
static unlinkUserAndChat(userId: string, chatId: string) {
|
||||||
|
this.database.prepare(`DELETE FROM UserFavouriteChatLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检测用户和对话的关联
|
||||||
|
*/
|
||||||
|
static checkUserIsLinkedToChat(userId: string, chatId: string) {
|
||||||
|
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取用户所有关联的对话
|
||||||
|
*/
|
||||||
|
static getUserFavouriteChats(userId: string) {
|
||||||
|
return this.findAllByCondition('user_id = ?', userId).map((v) => v.chat_id) as string[]
|
||||||
|
}
|
||||||
|
protected static findAllByCondition(condition: string, ...args: SQLInputValue[]) {
|
||||||
|
return this.database.prepare(`SELECT * FROM UserFavouriteChatLinker WHERE ${condition}`).all(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
server/data/UserRecentChatLinker.ts
Normal file
71
server/data/UserRecentChatLinker.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { DatabaseSync } from "node:sqlite"
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import RecentChatBean from './RecentChatBean.ts'
|
||||||
|
import config from "../config.ts"
|
||||||
|
import { SQLInputValue } from "node:sqlite"
|
||||||
|
|
||||||
|
export default class UserRecentChatLinker {
|
||||||
|
static database: DatabaseSync = this.init()
|
||||||
|
|
||||||
|
private static init(): DatabaseSync {
|
||||||
|
const db: DatabaseSync = new DatabaseSync(path.join(config.data_path, 'UserRecentChatLinker.db'))
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS UserRecentChatLinker (
|
||||||
|
/* 序号 */ count INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
/* 用戶 ID */ user_id TEXT NOT NULL,
|
||||||
|
/* Chat ID */ chat_id TEXT NOT NULL,
|
||||||
|
/* Last Message Content */ content TEXT NOT NULL,
|
||||||
|
/* Last Update Time */ updated_time INT8 NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_id ON UserRecentChatLinker(user_id);`)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 若用户和对话未关联, 则进行关联
|
||||||
|
*/
|
||||||
|
static updateOrAddRecentChat(userId: string, chatId: string, content: string) {
|
||||||
|
if (!this.checkUserIsLinkedToChat(userId, chatId))
|
||||||
|
this.database.prepare(`INSERT INTO UserRecentChatLinker (
|
||||||
|
user_id,
|
||||||
|
chat_id,
|
||||||
|
content,
|
||||||
|
updated_time
|
||||||
|
) VALUES (?, ?, ?, ?);`).run(
|
||||||
|
userId,
|
||||||
|
chatId,
|
||||||
|
content,
|
||||||
|
Date.now()
|
||||||
|
)
|
||||||
|
else
|
||||||
|
this.database.prepare('UPDATE UserRecentChatLinker SET content = ?, updated_time = ? WHERE count = ?').run(
|
||||||
|
content,
|
||||||
|
Date.now(),
|
||||||
|
/* 既然已经 bind 了, 那么就不需要判断了? */
|
||||||
|
this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId)[0].count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 解除用户和对话的关联
|
||||||
|
*/
|
||||||
|
static removeRecentChat(userId: string, chatId: string) {
|
||||||
|
this.database.prepare(`DELETE FROM UserRecentChatLinker WHERE user_id = ? AND chat_id = ?`).run(userId, chatId)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检测用户和对话的关联
|
||||||
|
*/
|
||||||
|
static checkUserIsLinkedToChat(userId: string, chatId: string) {
|
||||||
|
return this.findAllByCondition('user_id = ? AND chat_id = ?', userId, chatId).length != 0
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取用户所有关联的对话
|
||||||
|
*/
|
||||||
|
static getUserRecentChatBeans(userId: string) {
|
||||||
|
return this.findAllByCondition('user_id = ? ORDER BY updated_time DESC', userId) as unknown as RecentChatBean[]
|
||||||
|
}
|
||||||
|
protected static findAllByCondition(condition: string, ...args: SQLInputValue[]) {
|
||||||
|
return this.database.prepare(`SELECT * FROM UserRecentChatLinker WHERE ${condition}`).all(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,22 @@ import fs from 'node:fs/promises'
|
|||||||
// @ts-types="npm:@types/express-fileupload"
|
// @ts-types="npm:@types/express-fileupload"
|
||||||
import fileUpload from 'express-fileupload'
|
import fileUpload from 'express-fileupload'
|
||||||
import FileUploadMiddleware from "./fileupload-middleware.ts"
|
import FileUploadMiddleware from "./fileupload-middleware.ts"
|
||||||
|
import chalk from "chalk"
|
||||||
|
|
||||||
export default async function createLingChairServer() {
|
export default async function createLingChairServer() {
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start
|
||||||
|
console.log(`${chalk.grey('[求]')} ${req.socket.remoteAddress} <- ${req.originalUrl} [${res.statusCode}] (with ${req.method}, ${duration}ms)`)
|
||||||
|
})
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
app.use('/', express.static(config.data_path + '/page_compiled'))
|
app.use('/', express.static(config.data_path + '/page_compiled'))
|
||||||
app.use(cookieParser())
|
app.use(cookieParser())
|
||||||
app.get('/config.json', (req, res) => {
|
app.get('/config.json', (req, res) => {
|
||||||
@@ -49,7 +62,7 @@ export default async function createLingChairServer() {
|
|||||||
try {
|
try {
|
||||||
await fs.rmdir(config.data_path + '/upload_cache')
|
await fs.rmdir(config.data_path + '/upload_cache')
|
||||||
// deno-lint-ignore no-empty
|
// deno-lint-ignore no-empty
|
||||||
} catch (_) {}
|
} catch (_) { }
|
||||||
app.use(fileUpload({
|
app.use(fileUpload({
|
||||||
limits: { fileSize: 2 * 1024 * 1024 * 1024 },
|
limits: { fileSize: 2 * 1024 * 1024 * 1024 },
|
||||||
useTempFiles: true,
|
useTempFiles: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user