Compare commits

..

31 Commits

Author SHA1 Message Date
CrescentLeaf
05d2779922 服务端不必检查密码
因为被哈希的空字符不空
2026-02-02 22:46:11 +08:00
CrescentLeaf
a9dbb9655b 消息和用户头像右键菜单查看原始数据 2026-01-25 16:04:48 +08:00
CrescentLeaf
2aa9425334 docs: readme 2026-01-25 00:59:22 +08:00
CrescentLeaf
ec527bafc6 refactor!: 重新实现最近对话和收藏对话的逻辑 (破坏性变更) 2026-01-25 00:50:14 +08:00
CrescentLeaf
44ada8206d feat(client): creat Group 2026-01-25 00:29:45 +08:00
CrescentLeaf
3044cabcaa chore: 修复可能潜在的空指针 2026-01-24 23:26:10 +08:00
CrescentLeaf
7e6cbbdce4 chore: 统一使用 import from xxx.ts 导入, 删去无用依赖引用 2026-01-24 23:25:54 +08:00
CrescentLeaf
9e3c1c554f ui(client): add insertHtml for MduiPatchedTextArea 2026-01-24 00:11:19 +08:00
CrescentLeaf
01ece27e75 ui(client): improve Message loading 2026-01-24 00:10:47 +08:00
CrescentLeaf
da505305a3 chore(client-protocol): export some class & types 2026-01-23 22:32:28 +08:00
CrescentLeaf
200f5fd0aa chore: 后端方法注释 2026-01-17 00:59:11 +08:00
CrescentLeaf
d35ce7a255 feat(wip): 更加抽象的获取历史消息的方式
* 从某处作为锚点吗......
2026-01-17 00:32:48 +08:00
CrescentLeaf
326d62a8bd fix: LazyChatFragment 的依赖忘记修改导致无法使用 2026-01-16 23:45:19 +08:00
CrescentLeaf
25e5650441 chore: 移除了一条注释 2026-01-16 23:44:33 +08:00
CrescentLeaf
263a02e0c7 修复历史消息的列表顺序, 添加消息文本为空的检测 2026-01-09 23:28:27 +08:00
CrescentLeaf
687088a284 修复合并对话消息导致的右键菜单报错问题 2026-01-09 22:46:14 +08:00
CrescentLeaf
922791a0f5 feat(wip): 拉取历史消息 2026-01-09 00:44:53 +08:00
CrescentLeaf
6bfa1bf6b7 隐藏暂时无用的返回按钮 2026-01-09 00:26:21 +08:00
CrescentLeaf
6aa985bfca 分离群组设置 2026-01-09 00:21:07 +08:00
CrescentLeaf
7db95ed677 feat: 自定义多行富文本输入框! 2026-01-08 23:10:25 +08:00
CrescentLeaf
82de2eff42 feat(wip): 自定义编辑框 2026-01-04 23:28:09 +08:00
CrescentLeaf
3bada7c431 WIP: MduiCustomTextArea 2026-01-04 17:39:22 +08:00
CrescentLeaf
d4557ca0ae 乱了, 懒得说是什么 2026-01-02 01:27:32 +08:00
CrescentLeaf
bc603b8171 feat(WIP, unstable): 自定义输入框 2026-01-02 01:27:21 +08:00
CrescentLeaf
512419c131 fix: Cannot set properties of undefined (setting 'open') in opening ChatFragmentDialog 2026-01-01 20:52:17 +08:00
CrescentLeaf
ba97ea359a 完善对话页面!
* 消息组件显示移植
* 最近对话直接打开的补充
* 提及的修补
* ......
2026-01-01 19:39:04 +08:00
CrescentLeaf
722b06c018 自动显示当前对话标题! 2026-01-01 19:23:12 +08:00
CrescentLeaf
4e57a5f9e9 fix: favourite_chats 2026-01-01 18:52:12 +08:00
CrescentLeaf
72ca6a2fca BREAKING: 再见, contacts_list 2026-01-01 18:46:34 +08:00
CrescentLeaf
6e0a89f861 允许设定获取的消息的最大限制 2026-01-01 16:02:14 +08:00
CrescentLeaf
c423117ad5 忽略 mdui_patched 的代码统计 2026-01-01 15:54:11 +08:00
54 changed files with 1290 additions and 403 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
mdui_patched/** linguist-vendored -diff

View File

@@ -81,24 +81,25 @@ export default class Chat extends BaseClientObject {
* 对话消息
* ================================================
*/
async getMessages(args: { page?: number, offset?: number }) {
async getMessages(args: { page?: number, offset?: number, limit?: number }) {
return (await this.getMessageBeans(args)).map((v) => new Message(this.client, v))
}
async getMessagesOrThrow(args: { page?: number, offset?: number }) {
async getMessagesOrThrow(args: { page?: number, offset?: number, limit?: number }) {
return (await this.getMessageBeansOrThrow(args)).map((v) => new Message(this.client, v))
}
async getMessageBeans(args: { page?: number, offset?: number }) {
async getMessageBeans(args: { page?: number, offset?: number, limit?: number }) {
try {
return await this.getMessageBeansOrThrow(args)
} catch (_) {
return []
}
}
async getMessageBeansOrThrow({ page, offset }: { page?: number, offset?: number }) {
async getMessageBeansOrThrow({ page, offset, limit }: { page?: number, offset?: number, limit?: number }) {
const re = await this.client.invoke("Chat.getMessageHistory", {
token: this.client.access_token,
page,
offset,
limit,
target: this.bean.id,
})
if (re.code == 200) return re.data!.messages as MessageBean[]

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

View File

@@ -3,8 +3,7 @@ import MessageBean from "./bean/MessageBean.ts"
import LingChairClient from "./LingChairClient.ts"
import Chat from "./Chat.ts"
import User from "./User.ts"
import CallbackError from "./CallbackError.ts"
import ApiCallbackMessage from "./ApiCallbackMessage.ts"
import ChatAttachment from "./ChatAttachment.ts"
import * as marked from 'marked'
@@ -37,93 +36,18 @@ export class ChatMention extends BaseClientObject {
}
}
type FileType = 'Video' | 'Image' | 'File'
type MentionType = 'ChatMention' | 'UserMention'
type ChatMentionType = 'ChatMention' | 'UserMention'
type ChatFileType = 'Video' | 'Image' | 'File'
export 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
type ChatParserTransformers = {
attachment?: ({ text, fileType, attachment }: { text: string, fileType: ChatFileType, attachment: ChatAttachment }) => string,
mention?: ({ text, mentionType, mention }: { text: string, mentionType: ChatMentionType, mention: ChatMention }) => string,
}
export type {
ChatMentionType,
ChatFileType,
ChatParserTransformers,
}
export default class Message extends BaseClientObject {
@@ -152,10 +76,7 @@ export default class Message extends BaseClientObject {
parseWithTransformers({
attachment,
mention,
}: {
attachment?: ({ text, fileType, attachment }: { text: string, fileType: FileType, attachment: ChatAttachment }) => string,
mention?: ({ text, mentionType, mention }: { text: string, mentionType: MentionType, mention: ChatMention }) => string,
}) {
}: ChatParserTransformers) {
return new marked.Marked({
async: false,
extensions: [
@@ -178,8 +99,8 @@ export default class Message extends BaseClientObject {
{
name: 'image',
renderer: ({ text, href }) => {
const mentionType = /^(UserMention|ChatMention)=.*/.exec(text)?.[1] as MentionType
const fileType = (/^(Video|File|Image)=.*/.exec(text)?.[1] || 'Image') as FileType
const mentionType = /^(UserMention|ChatMention)=.*/.exec(text)?.[1] as ChatMentionType
const fileType = (/^(Video|File|Image)=.*/.exec(text)?.[1] || 'Image') as ChatFileType
if (fileType != null && /tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
const file_hash = /^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]!

View File

@@ -7,10 +7,16 @@ import GroupSettingsBean from "./bean/GroupSettingsBean.ts"
import JoinRequestBean from "./bean/JoinRequestBean.ts"
import MessageBean from "./bean/MessageBean.ts"
import RecentChatBean from "./bean/RecentChatBean.ts"
import Message, { ChatAttachment, ChatMention } from "./Message.ts"
import Message, {
ChatMention,
ChatParserTransformers,
ChatMentionType,
ChatFileType,
} from "./Message.ts"
import LingChairClient from "./LingChairClient.ts"
import CallbackError from "./CallbackError.ts"
import ChatAttachment from "./ChatAttachment.ts"
export {
LingChairClient,
@@ -19,6 +25,7 @@ export {
Chat,
User,
UserMySelf,
Message,
ChatAttachment,
ChatMention,
@@ -29,4 +36,10 @@ export {
RecentChatBean,
JoinRequestBean,
}
export type { GroupSettingsBean }
export type {
ChatParserTransformers,
ChatMentionType,
ChatFileType,
GroupSettingsBean,
}

View File

@@ -0,0 +1,5 @@
import ChatAttachment from '../ChatAttachment.ts'
import { ChatMention } from '../Message.ts'
import ChatFileType from './ChatFileType.ts'

View File

@@ -1,5 +1,5 @@
import { Chat, User, UserMySelf } from "lingchair-client-protocol"
import getClient from "./getClient"
import getClient from "./getClient.ts"
type CouldCached = User | Chat | null
export default class ClientCache {

14
client/env.d.ts vendored
View File

@@ -1,6 +1,20 @@
/// <reference types="mdui/jsx.zh-cn.d.ts" />
/// <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 __GIT_HASH__: string
declare const __GIT_HASH_FULL__: string

View File

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

View File

@@ -14,6 +14,8 @@ import './ui/chat-elements/chat-text.ts'
import './ui/chat-elements/chat-mention.ts'
import './ui/chat-elements/chat-text-container.ts'
import './ui/chat-elements/chat-quote.ts'
import './ui/MduiPatchedTextAreaElement.ts'
import './ui/InnerTextContainerElement.ts'
import Main from "./ui/Main.tsx"
import performAuth from './performAuth.ts'
@@ -36,6 +38,3 @@ const onResize = () => {
// deno-lint-ignore no-window no-window-prefix
window.addEventListener('resize', onResize)
onResize()
const config = await fetch('/config.json').then((re) => re.json())
config.title && (document.title = config.title)

View 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)

View File

@@ -10,7 +10,7 @@ import { CallbackError, Chat, UserMySelf } from "lingchair-client-protocol"
import showCircleProgressDialog from "./showCircleProgressDialog.ts"
import RegisterDialog from "./main-page/RegisterDialog.tsx"
import sleep from "../utils/sleep.ts"
import { $, dialog, NavigationDrawer } from "mdui"
import { $, NavigationDrawer } from "mdui"
import getClient from "../getClient.ts"
import showSnackbar from "../utils/showSnackbar.ts"
import AllChatsList from "./main-page/AllChatsList.tsx"
@@ -135,7 +135,7 @@ function Root() {
}}></mdui-divider>
<mdui-list-item rounded icon="settings"></mdui-list-item>
<mdui-list-item rounded icon="person_add" onClick={() => AppStateRef.current!.openAddFavouriteChat()}></mdui-list-item>
<mdui-list-item rounded icon="group_add"></mdui-list-item>
<mdui-list-item rounded icon="group_add" onClick={() => AppStateRef.current!.openCreateGroup()}></mdui-list-item>
</mdui-list>
<div style={{
flexGrow: 1,

View File

@@ -1,6 +1,6 @@
import { Chat, UserMySelf } from "lingchair-client-protocol"
import { Chat } from "lingchair-client-protocol"
import { createContext } from "use-context-selector"
import { SharedState } from "./MainSharedReducer"
import { SharedState } from "./MainSharedReducer.ts"
type Shared = {
functions_lazy: React.MutableRefObject<{

View File

@@ -1,4 +1,4 @@
import { Chat, UserMySelf } from "lingchair-client-protocol"
import { Chat } from "lingchair-client-protocol"
export interface SharedState {
favouriteChats: Chat[]

View 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)

View File

@@ -1,5 +1,5 @@
import EffectOnly from "./EffectOnly"
import showCircleProgressDialog from "./showCircleProgressDialog"
import EffectOnly from "./EffectOnly.tsx"
import showCircleProgressDialog from "./showCircleProgressDialog.ts"
export default function ProgressDialogFallback({ text }: { text: string }) {
return <EffectOnly effect={() => {

View File

@@ -1,13 +1,9 @@
import * as React from 'react'
import { Button, Dialog, snackbar, TextField } from "mdui"
import { data, useNavigate } from 'react-router'
import { useContextSelector } from 'use-context-selector'
import MainSharedContext, { Shared } from '../MainSharedContext'
import showSnackbar from '../../utils/showSnackbar'
import { Dialog, TextField } from "mdui"
import showSnackbar from '../../utils/showSnackbar.ts'
import { CallbackError } from 'lingchair-client-protocol'
import useEventListener from '../../utils/useEventListener'
import ClientCache from '../../ClientCache'
import AppStateContext from './AppStateContext'
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)

View File

@@ -1,14 +1,14 @@
import { Chat, User } from 'lingchair-client-protocol'
import { Dialog } from 'mdui'
import * as React from 'react'
type AppState = {
openChatInfo: (chat: Chat | string) => void,
openUserInfo: (user: Chat | User | string) => void,
openEditMyProfile: () => void,
openAddFavouriteChat: () => void,
openChat: (chat: string | Chat) => void,
closeChat: () => void,
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>({
@@ -16,6 +16,7 @@ const AppStateContext = React.createContext<AppState>({
openUserInfo: () => {},
openEditMyProfile: () => {},
openAddFavouriteChat: () => {},
openCreateGroup: () => {},
openChat: () => {},
closeChat: () => {},
})

View File

@@ -1,15 +1,20 @@
import { $, Dialog } from "mdui"
import AppStateContext, { AppState } from "./AppStateContext"
import AppStateContext, { AppState } from "./AppStateContext.ts"
import { Chat, User } from "lingchair-client-protocol"
import getClient from "../../getClient"
import UserOrChatInfoDialog from "./UserOrChatInfoDialog"
import useEffectRef from "../../utils/useEffectRef"
import EditMyProfileDialog from "./EditMyProfileDialog"
import AddFavourtieChatDialog from "./AddFavourtieChatDialog"
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"
import ChatFragmentDialog from "./ChatFragmentDialog"
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[]>([])
@@ -29,6 +34,7 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
const editMyProfileDialogRef = React.useRef<Dialog>()
const addFavouriteChatDialogRef = React.useRef<Dialog>()
const createGroupDialogRef = React.useRef<Dialog>()
const setCurrentSelectedChatId = useContextSelector(
MainSharedContext,
@@ -38,9 +44,12 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
MainSharedContext,
(context: Shared) => context.state.currentSelectedChatId
)
const [useChatFragmentDialog, setUseChatFragmentDialog] = React.useState(false)
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))
@@ -59,13 +68,16 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
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()
setUseChatFragmentDialog(inDialog || false)
setUserOrChatInfoDialogState([])
setCurrentSelectedChatId(chat)
chatFragmentDialogRef.current!.open = true
inDialog && (chatFragmentDialogRef.current!.open = true)
}
static closeChat() {
if (chatFragmentDialogRef.current!.open) {
@@ -76,10 +88,11 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
setCurrentSelectedChatId('')
}
}}>
<ChatFragmentDialog chatId={currentSelectedChatId} useRef={chatFragmentDialogRef} />
<UserOrChatInfoDialog chat={userOrChatInfoDialogState[userOrChatInfoDialogState.length - 1] || lastUserOrChatInfoDialogStateRef.current} useRef={userOrChatInfoDialogRef} />
<EditMyProfileDialog useRef={editMyProfileDialogRef} />
<AddFavourtieChatDialog useRef={addFavouriteChatDialogRef} />
{useChatFragmentDialog && currentSelectedChatId && currentSelectedChatId != '' && <ChatFragmentDialog chatId={currentSelectedChatId} useRef={chatFragmentDialogRef} />}
<CreateGroupDialog useRef={createGroupDialogRef} />
{children}
</AppStateContext.Provider>
}

View File

@@ -1,9 +1,10 @@
import { Dialog } from "mdui"
import * as React from 'react'
import LazyChatFragment from "../chat-fragment/LazyChatFragment"
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> }) {
React.useEffect(() => {
useEventListener(useRef, 'open', () => {
const shadow = useRef.current!.shadowRoot as ShadowRoot
const panel = shadow.querySelector(".panel") as HTMLElement
panel.style.padding = '0'
@@ -13,14 +14,14 @@ export default function ChatFragmentDialog({ chatId, useRef }: { chatId: string,
const body = shadow.querySelector(".body") as HTMLElement
body.style.height = '100%'
body.style.display = 'flex'
}, [chatId])
})
return <mdui-dialog fullscreen ref={useRef}>
<div style={{
display: 'flex',
width: '100%',
}}>
<LazyChatFragment chatId={chatId} openedInDialog={true} />
{chatId != null && chatId != '' && <LazyChatFragment chatId={chatId} openedInDialog={true} />}
</div>
</mdui-dialog>
}

View 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>
)
}

View File

@@ -1,11 +1,10 @@
import { CallbackError, UserMySelf } from "lingchair-client-protocol"
import ClientCache from "../../ClientCache"
import AvatarMySelf from "../AvatarMySelf"
import useAsyncEffect from "../../utils/useAsyncEffect"
import getClient from "../../getClient"
import { useNavigate } from "react-router"
import showSnackbar from "../../utils/showSnackbar"
import useEventListener from "../../utils/useEventListener"
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'

View File

@@ -1,27 +1,21 @@
import { Dialog, dialog } from "mdui"
import { useLoaderData, useNavigate } from "react-router"
import { CallbackError, Chat } from "lingchair-client-protocol"
import showSnackbar from "../../utils/showSnackbar"
import Avatar from "../Avatar"
import showSnackbar from "../../utils/showSnackbar.ts"
import Avatar from "../Avatar.tsx"
import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext"
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
import * as React from 'react'
import ClientCache from "../../ClientCache"
import getClient from "../../getClient"
import isMobileUI from "../../utils/isMobileUI"
import useEffectRef from "../../utils/useEffectRef"
import useAsyncEffect from "../../utils/useAsyncEffect"
import AppStateContext from "./AppStateContext"
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 setCurrentSelectedChatId = useContextSelector(
MainSharedContext,
(context: Shared) => context.setCurrentSelectedChatId
)
const AppState = React.useContext(AppStateContext)
@@ -110,7 +104,7 @@ export default function UserOrChatInfoDialog({ chat, useRef }: { chat?: Chat, us
})}>{favourited ? '取消收藏' : '收藏对话'}</mdui-list-item>
}
<mdui-list-item icon="chat" rounded onClick={async () => {
AppState.openChat(chat!)
AppState.openChat(chat!, isMobileUI())
}}></mdui-list-item>
</mdui-list>
</mdui-dialog>

View File

@@ -1,8 +1,15 @@
import { $ } from 'mdui'
import showSnackbar from "../../utils/showSnackbar.ts";
customElements.define('chat-mention', class extends HTMLElement {
import showSnackbar from "../../utils/showSnackbar.ts"
import { Chat, User } from 'lingchair-client-protocol'
export default class ChatMentionElement extends HTMLElement {
declare link: HTMLAnchorElement
static observedAttributes = ['user-id']
// 这两个方法应当在被渲染后由渲染组件主动提供
declare openChatInfo?: (chat: Chat | string) => void
declare openUserInfo?: (user: Chat | User | string) => void
constructor() {
super()
@@ -30,18 +37,14 @@ customElements.define('chat-mention', class extends HTMLElement {
const text = $(this).attr('text')
this.link.style.fontStyle = ''
if (chatId) {
this.link.onclick = (e) => {
e.stopPropagation()
// deno-lint-ignore no-window
this.openChatInfo?.(chatId)
}
} else if (userId) {
this.link.onclick = (e) => {
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)

View File

@@ -29,9 +29,9 @@ customElements.define('chat-text', class extends HTMLElement {
// 避免不同的消息类型之间的换行符导致显示异常
if (isFirstElementInParent)
this.span.textContent = this.textContent.trimStart()
this.span.textContent = (this.textContent || '').trimStart()
else if (isLastElementInParent)
this.span.textContent = this.textContent.trimEnd()
this.span.textContent = (this.textContent || '').trimEnd()
else
this.span.textContent = this.textContent
this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : ''

View File

@@ -1,18 +1,18 @@
import { $, Tab, TextField } from "mdui"
import useEventListener from "../../utils/useEventListener"
import useEffectRef from "../../utils/useEffectRef"
import isMobileUI from "../../utils/isMobileUI"
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"
import PreferenceHeader from "../preference/PreferenceHeader"
import PreferenceLayout from "../preference/PreferenceLayout"
import PreferenceUpdater from "../preference/PreferenceUpdater"
import SwitchPreference from "../preference/SwitchPreference"
import TextFieldPreference from "../preference/TextFieldPreference"
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"
import ChatPanel, { ChatPanelRef } from "./ChatPanel"
import AppStateContext from "../app-state/AppStateContext.ts"
import ChatPanel, { ChatPanelRef } from "./ChatPanel.tsx"
interface MduiTabFitSizeArgs extends React.HTMLAttributes<HTMLElement & Tab> {
value: string
@@ -44,7 +44,21 @@ export default function ChatFragment({
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={{
@@ -91,6 +105,8 @@ export default function ChatFragment({
<div style={{
flexGrow: '1',
}}></div>
{
/*
<mdui-button-icon icon="open_in_new" onClick={() => {
window.open('/chat?id=' + chatInfo.getId(), '_blank')
}} style={{
@@ -98,6 +114,8 @@ export default function ChatFragment({
marginLeft: '5px',
marginRight: '5px',
}}></mdui-button-icon>
*/
}
<mdui-button-icon icon="refresh" onClick={() => {
}} style={{
@@ -157,13 +175,14 @@ export default function ChatFragment({
}} onDrop={(e) => {
// 文件拽入
}}>
<mdui-text-field variant="outlined" placeholder="(。・ω・。)" autosize ref={inputRef} max-rows={6} onChange={() => {
<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 || []) {
@@ -177,7 +196,8 @@ export default function ChatFragment({
marginRight: '10px',
marginTop: '3px',
marginBottom: '3px',
}}></mdui-text-field>
}}>
</mdui-text-field>
<mdui-button-icon slot="end-icon" icon="attach_file" style={{
marginRight: '6px',
}} onClick={() => {
@@ -185,11 +205,7 @@ export default function ChatFragment({
}}></mdui-button-icon>
<mdui-button-icon icon="send" style={{
marginRight: '7px',
}} onClick={async () => {
// 发送消息
await chatInfo.sendMessageOrThrow(inputRef.current!.value)
inputRef.current!.value = ''
}}></mdui-button-icon>
}} onClick={performSendMessage}></mdui-button-icon>
<div style={{
display: 'none'
}}>
@@ -226,68 +242,7 @@ export default function ChatFragment({
<input accept="image/*" type="file" name="上传对话头像"></input>
</div>
{
/* 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> */
// 群组设置?
}
{
chatInfo.getType() == 'private' && (

View File

@@ -1,5 +1,271 @@
import { Message } from "lingchair-client-protocol"
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"
export default function ChatMessage({ message }: { message: Message }) {
return null
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>
</>
}

View File

@@ -1,5 +1,7 @@
import { Chat, Message } from 'lingchair-client-protocol'
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 (
@@ -12,7 +14,95 @@ export default function ChatMessageContainer({ messages }: { messages: Message[]
paddingTop: "15px",
flexGrow: '1',
}}>
{messages?.map((v) => v.getText())}
{
(() => {
// 添加时间
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>
)
}

View File

@@ -1,6 +1,6 @@
import { Chat, Message } from "lingchair-client-protocol"
import ChatMessageContainer from "./ChatMessageContainer"
import useAsyncEffect from "../../utils/useAsyncEffect"
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>) {

View File

@@ -1,14 +1,18 @@
import { Chat } from "lingchair-client-protocol"
import { Await } from "react-router"
import getClient from "../../getClient"
import ChatFragment from "./ChatFragment"
import getClient from "../../getClient.ts"
import ChatFragment from "./ChatFragment.tsx"
import * as React from 'react'
import EffectOnly from "../EffectOnly"
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
export default function LazyChatFragment({ chatId, openedInDialog }: { chatId: string, openedInDialog: boolean }) {
return <React.Suspense fallback={<EffectOnly effect={() => {}} deps={[]} />}>
<Await
resolve={React.useMemo(() => Chat.getByIdOrThrow(getClient(), chatId), [chatId])}
children={(chatInfo: Chat) => <ChatFragment chatInfo={chatInfo} openedInDialog={openedInDialog} />} />
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>
}

View 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> */
}

View File

@@ -8,9 +8,7 @@ import showSnackbar from "../../utils/showSnackbar.ts"
import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext.ts"
import isMobileUI from "../../utils/isMobileUI.ts"
import gotoChatInfo from "../routers/gotoChatInfo.ts"
import ClientCache from "../../ClientCache.ts"
import { useNavigate } from "react-router"
import AppStateContext from "../app-state/AppStateContext.ts"
export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HTMLElement>) {
@@ -25,7 +23,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
const [searchText, setSearchText] = React.useState('')
const [checkedList, setCheckedList] = React.useState<{ [key: string]: boolean }>({})
const DialogState = React.useContext(AppStateContext)
const AppState = React.useContext(AppStateContext)
useEventListener(searchRef, 'input', (e) => {
setSearchText((e.target as unknown as TextField).value)
@@ -75,7 +73,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
<mdui-list-item rounded style={{
marginTop: '13px',
width: '100%',
}} icon="person_add" onClick={() => DialogState.openAddFavouriteChat()}></mdui-list-item>
}} icon="person_add" onClick={() => AppState.openAddFavouriteChat()}></mdui-list-item>
<mdui-list-item rounded style={{
width: '100%',
}} icon="refresh" onClick={() => shared.functions_lazy.current.updateFavouriteChats()}></mdui-list-item>
@@ -162,7 +160,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes<HT
[v.getId()]: !checkedList[v.getId()],
})
else
DialogState.openChatInfo(v.getId())
AppState.openChatInfo(v.getId())
}}
key={v.getId()}
chat={v} />

View File

@@ -2,7 +2,6 @@ import { TextField } from "mdui"
import RecentsListItem from "./RecentsListItem.tsx"
import React from "react"
import RecentChat from "lingchair-client-protocol/RecentChat.ts"
import { data } from "react-router"
import isMobileUI from "../../utils/isMobileUI.ts"
import useAsyncEffect from "../../utils/useAsyncEffect.ts"
import useEventListener from "../../utils/useEventListener.ts"
@@ -11,8 +10,10 @@ import { useContextSelector } from "use-context-selector"
import showSnackbar from "../../utils/showSnackbar.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>) {
const AppState = React.useContext(AppStateContext)
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({
functions_lazy: context.functions_lazy,
state: context.state,
@@ -75,7 +76,7 @@ export default function RecentChatsList({ ...props }: React.HTMLAttributes<HTMLE
).map((v) =>
<RecentsListItem
active={isMobileUI() ? false : shared.state.currentSelectedChatId == v.getId()}
onClick={() => {}}
onClick={() => AppState.openChat(v.getId(), isMobileUI())}
key={v.getId()}
recentChat={v} />
)

View File

@@ -1,12 +1,12 @@
import * as React from 'react'
import { Button, Dialog, TextField } from "mdui"
import MainSharedContext, { Shared } from '../MainSharedContext'
import showSnackbar from '../../utils/showSnackbar'
import showCircleProgressDialog from '../showCircleProgressDialog'
import getClient from '../../getClient'
import performAuth from '../../performAuth'
import { Dialog, TextField } from "mdui"
import MainSharedContext, { Shared } from '../MainSharedContext.ts'
import showSnackbar from '../../utils/showSnackbar.ts'
import showCircleProgressDialog from '../showCircleProgressDialog.ts'
import getClient from '../../getClient.ts'
import performAuth from '../../performAuth.ts'
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>) {
const shared = useContextSelector(MainSharedContext, (context: Shared) => ({

View File

@@ -1,5 +1,7 @@
import data from "../data"
import data from "../data.ts"
const searchParams = new URL(location.href).searchParams
export default function isMobileUI() {
return data.override_use_mobile_ui || /Mobi|Android|iPhone/i.test(navigator.userAgent)
return data.override_use_mobile_ui || searchParams.get('mobile') == 'true' || /Mobi|Android|iPhone/i.test(navigator.userAgent)
}

View File

@@ -1,5 +1,3 @@
import { Dialog } from "mdui"
import { BlockerFunction, useBlocker, useNavigate } from "react-router"
import * as React from 'react'
export default function useEffectRef<T = undefined>(effect: (ref: React.MutableRefObject<T | undefined>) => void | (() => void), deps?: React.DependencyList) {

View File

@@ -1,7 +1,9 @@
import * as React from 'react'
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(() => {
// console.warn(ref, eventName, callback)
ref.current!.addEventListener(eventName, callback)
return () => ref.current?.removeEventListener(eventName, callback)
}, [ref, eventName, callback])

View File

@@ -61,3 +61,5 @@ export const CallableMethodBeforeAuth = [
"User.login",
"User.refreshAccessToken",
]
export const PageFetchMaxLimit = 15

View File

@@ -398,9 +398,15 @@ let TextField = class TextField extends FocusableMixin(MduiElement) {
'is-firefox': navigator.userAgent.includes('Firefox'),
...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.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) {
this.inputRef.value.setCustomValidity(message);
@@ -528,6 +534,11 @@ let TextField = class TextField extends FocusableMixin(MduiElement) {
? 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
: 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>`;
}
/**
* @param hasError 是否包含错误提示
* @param hasHelper 是否含 helper 属性或 helper slot

View File

@@ -6,31 +6,27 @@
铃之椅, 一个普通的即时通讯项目——简单, 轻量, 纯粹, 时而天真
*目前还没有发布正式版本, 仍在积极开发中*
***仍在开发阶段, 随时都可能有破坏性变更!***
项目代号: TheWhiteSilk
### 基本功能
### 目前的功能
<details>
<summary>客户端</summary>
<summary>客户端</summary>
*: 重构中
- 消息
- [x] 收发消息
- [x] 富文本 (based on Marked)
- [x] 图片
- [x] 视频
- [x] 文件
- [ ] 测试其他 Markdown 语法的可用性
- [ ] *收发消息
- [x] 富文本
- [ ] 撤回消息
- [ ] 修改消息
- 对话
- [x] 最近对话
- [x] 添加对话
- [x] 添加收藏对话
- [x] 添加用户
- [x] 添加群组
- [ ] 群组管理
- [ ] *群组管理
- 帐号
- [x] 登录注册
@@ -64,46 +60,48 @@
- [x] 登录注册
- [x] 资料编辑
- [ ] 帐号管理
- [ ] 重设密码
- [x] 重设密码 (不够好!)
- [ ] 绑定邮箱
</details>
### 快速上手
### 部署
```bash
git clone https://codeberg.org/CrescentLeaf/LingChair
cd LingChair
# 编译前端
deno task build
# 运行服务
deno task server
npm run install-dependencies
npm run build-client
npm run 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-plugin-babel
- react
- socket.io-client
- mdui
- split.js
- react-json-view
- ua-parser-js
- pinch-zoom
- use-context-selector
- dompurify
- marked
- 后端
- express
- express-fileupload
- socket.io
- chalk
- file-type

View File

@@ -34,6 +34,11 @@ export default class ChatApi extends BaseApi {
code: 400,
}
if (args.text == '') return {
code: 400,
msg: "消息文本为空",
}
const token = TokenManager.decode(args.token as string)
if (!this.checkToken(token, deviceId)) return {
code: 401,
@@ -116,7 +121,7 @@ export default class ChatApi extends BaseApi {
code: 200,
msg: "成功",
data: {
messages: MessagesManager.getInstanceForChat(chat)[args.page ? 'getMessagesWithPage' : 'getMessagesWithOffset'](null, (args.page ? args.page : args.offset) as number),
messages: MessagesManager.getInstanceForChat(chat)[args.page ? 'getMessagesWithPage' : 'getMessagesWithOffset'](args.limit as number | undefined, (args.page ? args.page : args.offset) as number),
},
}
})

View File

@@ -140,7 +140,12 @@ export default class UserApi extends BaseApi {
})
// 注冊
this.registerEvent("User.register", (args, { deviceId }) => {
if (this.checkArgsMissing(args, ['nickname', 'password'])) return {
// 判断密码是否为空已经没有意义
// 因为空字符的哈希值不为空
// 后续会修缮关于注册的机制
// 比如限制 IP, 需要邮箱
// 虽然我知道这没有什么意义
if (this.checkArgsEmpty(args, ['nickname'])) return {
msg: "参数缺失",
code: 400,
}
@@ -318,11 +323,11 @@ export default class UserApi extends BaseApi {
const user = User.findById(token.author) as User
const recentChats = user.getRecentChats()
const recentChatsList: any[] = []
for (const [chatId, content] of recentChats) {
const chat = Chat.findById(chatId)
for (const {chat_id, content} of recentChats) {
const chat = Chat.findById(chat_id)
recentChatsList.push({
content,
id: chatId,
id: chat_id,
title: chat?.getTitle(user) || "未知",
avatar_file_hash: chat?.getAvatarFileHash(user) ? chat?.getAvatarFileHash(user) : undefined
})
@@ -350,14 +355,13 @@ export default class UserApi extends BaseApi {
}
const user = User.findById(token.author) as User
const contacts = user.getFavouriteChats()
contacts.push(ChatPrivate.getChatIdByUsersId(token.author, token.author))
const favourite_chats = user.getFavouriteChats()
return {
msg: "成功",
code: 200,
data: {
contacts_list: contacts.map((id) => {
favourite_chats: favourite_chats.map((id) => {
const chat = Chat.findById(id)
return {
id,

View File

@@ -59,8 +59,13 @@ export default class Chat {
return new Chat(beans[0])
}
/**
* 对话创建基本方法
* @param chatName 对话别名, 供查询
* @param type 对话类型
*/
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} 已被使用`)
const chat = new Chat(
Chat.findAllChatBeansByCondition(
@@ -123,6 +128,11 @@ export default class Chat {
* ======================================================
*/
/**
* 添加加入请求
* @param userId
* @param reason
*/
addJoinRequest(userId: string, reason?: string) {
if (this.findAllJoinRequestsByCondition('user_id = ?', userId).length == 0)
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) {
if (!this.checkUserIsAdmin(userId))
Chat.database.prepare(`INSERT INTO ${this.getAdminsTableName()} (
@@ -185,6 +200,9 @@ export default class Chat {
* ======================================================
*/
/**
* 获取对话成员
*/
getMembersList() {
return UserChatLinker.getChatMembers(this.bean.id)
}
@@ -201,6 +219,10 @@ export default class Chat {
* ======================================================
*/
/**
* 从**私聊**中获取对方用户
* @param userMySelf
*/
getAnotherUserForPrivate(userMySelf: User) {
const members = this.getMembersList()
const user_a_id = members[0]

View File

@@ -11,6 +11,10 @@ class GroupSettings {
this.settings = JSON.parse(chat.bean.settings)
}
/**
* 覆盖群组设定
* @param bean 要覆盖的设定, 不需要覆盖的不需要填入
*/
update(bean: GroupSettingsBean) {
const updateValue = (key: string) => {
if (key in bean)
@@ -26,6 +30,9 @@ class GroupSettings {
this.apply()
}
/**
* 应用更改
*/
apply() {
this.chat.setAttr('settings', JSON.stringify(this.settings))
}
@@ -36,10 +43,19 @@ export default class ChatGroup extends Chat {
return new GroupSettings(this)
}
/**
* 确保是群组类型后, 转换成群组对话
* 唯一的作用可能是修改群组设定
* @param chat
*/
static fromChat(chat: Chat) {
return new ChatGroup(chat.bean)
}
/**
* 创建新的群组
* @param group_name 群组名称
*/
static createGroup(group_name?: string) {
return this.create(group_name, 'group')
}

View File

@@ -3,6 +3,12 @@ import Chat from "./Chat.ts"
import User from "./User.ts"
export default class ChatPrivate extends Chat {
/**
* 确保是私聊类型后, 转换成私聊对话
* 实际上没啥用, 因为实例方法都在 Chat
* 未来可能会移除
* @param chat
*/
static fromChat(chat: Chat) {
return new ChatPrivate(chat.bean)
}
@@ -11,6 +17,11 @@ export default class ChatPrivate extends Chat {
return 'priv_' + [userIdA, userIdB].sort().join('__').replaceAll('-', '_')
}
/**
* 为两个用户创建对话 (无需注意顺序)
* @param userA
* @param userB
*/
static createForPrivate(userA: User, userB: User) {
const chat = this.create(undefined, 'private')
chat.setAttr('id', this.getChatIdByUsersId(userA.bean.id, userB.bean.id))
@@ -19,11 +30,21 @@ export default class ChatPrivate extends Chat {
userB.bean.id
])
}
/**
* 寻找两个用户间的对话 (无需注意顺序)
* @param userA
* @param userB
*/
static findByUsersForPrivate(userA: User, userB: User) {
const chat = this.findById(this.getChatIdByUsersId(userA.bean.id, userB.bean.id))
if (chat)
return this.fromChat(chat as Chat)
}
/**
* 寻找两个用户间的对话, 若无则创建 (无需注意顺序)
* @param userA
* @param userB
*/
static findOrCreateForPrivate(userA: User, userB: User) {
let a = this.findByUsersForPrivate(userA, userB)
if (a == null) {

View File

@@ -36,6 +36,9 @@ class File {
getName() {
return this.bean.name
}
/**
* 获取文件的相对路径
*/
getFilePath() {
const hash = this.bean.hash
return path.join(
@@ -90,6 +93,12 @@ export default class FileManager {
return db
}
/**
* 上传文件 (与 HTTP API 对接)
* @param fileName 文件名
* @param data 文件二进制数据
* @param chatId 所属的对话
*/
static async uploadFile(fileName: string, data: Buffer, chatId?: string) {
const hash = crypto.createHash('sha256').update(data).digest('hex')
const file = FileManager.findByHash(hash)

View File

@@ -6,6 +6,7 @@ import chalk from "chalk"
import config from "../config.ts"
import Chat from "./Chat.ts"
import MessageBean from "./MessageBean.ts"
import { PageFetchMaxLimit } from "lingchair-internal-shared"
export default class MessagesManager {
static database: DatabaseSync = this.init()
@@ -15,6 +16,10 @@ export default class MessagesManager {
return db
}
/**
* 为对话获取实例
* @param chat 对话
*/
static getInstanceForChat(chat: Chat) {
return new MessagesManager(chat)
}
@@ -35,6 +40,9 @@ export default class MessagesManager {
protected getTableName() {
return `messages_${this.chat.bean.id}`.replaceAll('-', '_')
}
/**
* 添加一条消息
*/
addMessage({
text,
user_id,
@@ -54,19 +62,58 @@ export default class MessagesManager {
time || Date.now()
).lastInsertRowid
}
/**
* 添加一条无用户信息的系统消息
*/
addSystemMessage(text: string) {
this.addMessage({
text
})
}
getMessagesWithOffset(limit: number | undefined | null, offset: number = 0) {
const ls = MessagesManager.database.prepare(`SELECT * FROM ${this.getTableName()} ORDER BY id DESC LIMIT ? OFFSET ?;`).all(limit || 15, 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) => ({
...v,
chat_id: this.chat.bean.id,
}))
})).reverse()
}
/**
* 从最新消息开始偏移某些量向**前**获取第 n 页消息 (顺序: 从新到旧)
* @param limit 获取消息的数量
* @param page 页数
*/
getMessagesWithPage(limit: number | undefined | null, page: number = 0) {
return this.getMessagesWithOffset(limit, (limit || 15) * page)
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)
}
}

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

View File

@@ -11,11 +11,10 @@ import UserBean from './UserBean.ts'
import FileManager from './FileManager.ts'
import { SQLInputValue } from "node:sqlite"
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 UserChatLinker from "./UserChatLinker.ts";
import UserChatLinker from "./UserChatLinker.ts"
import UserFavouriteChatLinker from "./UserFavouriteChatLinker.ts"
import UserRecentChatLinker from "./UserRecentChatLinker.ts"
type UserBeanKey = keyof UserBean
@@ -38,8 +37,6 @@ export default class User {
/* 用户名 */ username TEXT,
/* 昵称 */ nickname TEXT NOT NULL,
/* 头像, 可选 */ avatar_file_hash TEXT,
/* 对话列表 */ contacts_list TEXT NOT NULL,
/* 最近对话 */ recent_chats TEXT NOT NULL,
/* 设置 */ settings TEXT NOT NULL
);
`)
@@ -49,6 +46,13 @@ export default class User {
return db
}
/**
* 检查用户名是否存在, 不存在则创建用户, 否则报错
* @param userName
* @param password
* @param nickName
* @param avatar
*/
static create(userName: string | null, password: string, nickName: string, avatar: Buffer | null) {
if (userName && User.findAllBeansByCondition('username = ?', userName).length > 0)
throw new DataWrongError(`用户名 ${userName} 已存在`)
@@ -62,18 +66,14 @@ export default class User {
username,
nickname,
avatar_file_hash,
contacts_list,
recent_chats,
settings
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(
) VALUES (?, ?, ?, ?, ?, ?, ?);`).run(
crypto.randomUUID(),
password,
Date.now(),
userName,
nickName,
null,
'[]',
JSON.stringify(new Map(), MapJson.replacer),
"{}"
).lastInsertRowid
)[0]
@@ -102,6 +102,10 @@ export default class User {
console.error(chalk.red(`警告: 查询 username = ${userName} 时, 查询到多个相同用户名的用户`))
return new User(beans[0])
}
/**
* 通过用户名或 ID 获取某个用户, 用户名优先
* @param account 用户名或用户 ID
*/
static findByAccount(account: string) {
return User.findByUserName(account) || User.findById(account)
}
@@ -125,37 +129,25 @@ export default class User {
this.setAttr("username", userName)
}
updateRecentChat(chatId: string, content: string) {
const map = JSON.parse(this.bean.recent_chats, MapJson.reviver) as Map<string, string>
map.delete(chatId)
map.set(chatId, content)
this.setAttr("recent_chats", JSON.stringify(map, MapJson.replacer))
UserRecentChatLinker.updateOrAddRecentChat(this.bean.id, chatId, content)
}
getRecentChats(): Map<string, string> {
try {
return JSON.parse(this.bean.recent_chats, MapJson.reviver)
} catch (e) {
console.log(chalk.yellow(`警告: 最近对话列表解析失敗: ${(e as Error).message}`))
return new Map()
getRecentChats() {
return UserRecentChatLinker.getUserRecentChatBeans(this.bean.id)
}
getFavouriteChats() {
return UserFavouriteChatLinker.getUserFavouriteChats(this.bean.id)
}
addFavouriteChats(chatIds: string[]) {
chatIds.forEach((v) => UserFavouriteChatLinker.linkUserAndChat(this.bean.id, v))
}
removeFavouriteChats(chatIds: string[]) {
chatIds.forEach((v) => UserFavouriteChatLinker.unlinkUserAndChat(this.bean.id, v))
}
addFavouriteChat(chatId: string) {
const ls = this.getFavouriteChats()
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))
}
removeFavouriteChats(contacts: string[]) {
const ls = this.getFavouriteChats().filter((v) => !contacts.includes(v))
this.setAttr("contacts_list", JSON.stringify(ls))
}
getFavouriteChats() {
try {
return JSON.parse(this.bean.contacts_list) as string[]
} catch (e) {
console.log(chalk.yellow(`警告: 所有对话解析失败: ${(e as Error).message}`))
return []
}
this.addFavouriteChats([chatId])
}
getAllChatsList() {
return UserChatLinker.getUserChats(this.bean.id)
}

View File

@@ -6,7 +6,7 @@ export default class UserBean {
declare registered_time: number
declare nickname: string
declare avatar_file_hash?: string
declare contacts_list: string
declare favourite_chats: string
declare recent_chats: string
declare settings: string

View File

@@ -21,8 +21,7 @@ export default class UserChatLinker {
}
/**
* 對用戶和對話建立關聯
* 自動檢測是否已關聯, 保證不會重複
* 若用户和对话未关联, 则进行关联
*/
static linkUserAndChat(userId: string, chatId: string) {
if (!this.checkUserIsLinkedToChat(userId, chatId))
@@ -34,15 +33,27 @@ export default class UserChatLinker {
chatId
)
}
/**
* 解除用户和对话的关联
*/
static unlinkUserAndChat(userId: string, chatId: string) {
this.database.prepare(`DELETE FROM UserChatLinker 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 getUserChats(userId: string) {
return this.findAllByCondition('user_id = ?', userId).map((v) => v.chat_id) as string[]
}
/**
* 获取对话所有关联的用户
*/
static getChatMembers(chatId: string) {
return this.findAllByCondition('chat_id = ?', chatId).map((v) => v.user_id) as string[]
}

View 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)
}
}

View 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)
}
}