Compare commits

...

4 Commits

Author SHA1 Message Date
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
11 changed files with 347 additions and 30 deletions

View File

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

@@ -10,6 +10,10 @@ import * as React from 'react'
import { useContextSelector } from "use-context-selector"
import MainSharedContext, { Shared } from "../MainSharedContext"
import ChatFragmentDialog from "./ChatFragmentDialog"
import useAsyncEffect from "../../utils/useAsyncEffect"
import ClientCache from "../../ClientCache"
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[]>([])
@@ -41,6 +45,10 @@ export default function DialogContextWrapper({ children, useRef }: { children: R
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))

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()
@@ -57,4 +64,6 @@ customElements.define('chat-mention', class extends HTMLElement {
}
}
}
})
}
customElements.define('chat-mention', ChatMentionElement)

View File

@@ -44,7 +44,10 @@ 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 = ''
}
return (
<div style={{
@@ -164,6 +167,7 @@ export default function ChatFragment({
}} onKeyDown={(event: KeyboardEvent) => {
if (event.ctrlKey && event.key == 'Enter') {
// 发送消息
performSendMessage()
}
}} onPaste={(event: ClipboardEvent) => {
for (const item of event.clipboardData?.items || []) {
@@ -185,11 +189,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'
}}>

View File

@@ -1,5 +1,263 @@
import { Message } from "lingchair-client-protocol"
import isMobileUI from "../../utils/isMobileUI"
import useAsyncEffect from "../../utils/useAsyncEffect"
import ClientCache from "../../ClientCache"
import getClient from "../../getClient"
import Avatar from "../Avatar"
import AppStateContext from "../app-state/AppStateContext"
import { $, dialog, Dropdown } from "mdui"
import useEventListener from "../../utils/useEventListener"
import DOMPurify from 'dompurify'
import * as React from 'react'
import ChatMentionElement from "../chat-elements/chat-mention"
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',
],
}
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 [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('')
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() || '') || '')
}, [message])
const messageInnerRef = React.useRef<HTMLSpanElement>(null)
React.useEffect(() => {
messageInnerRef.current!.innerHTML = prettyFlatParsedMessage(DOMPurify.sanitize(message.parseWithTransformers({
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>`
}
},
}), 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
slot="trigger"
onContextMenu={(e) => {
if (isMobileUI()) return
e.preventDefault()
setMessageDropDownOpen(!isMessageDropDownOpen)
}}
onClick={(e) => {
if (!isMobileUI()) return
e.preventDefault()
setMessageDropDownOpen(!isMessageDropDownOpen)
}}
style={{
width: "100%",
display: "flex",
justifyContent: isAtRight ? "flex-end" : "flex-start",
flexDirection: "column"
}}>
{
!noUserDisplay &&
<div
style={{
display: "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,6 @@
import { Chat, Message } from 'lingchair-client-protocol'
import * as React from 'react'
import ChatMessage from './ChatMessage'
export default function ChatMessageContainer({ messages }: { messages: Message[] }) {
return (
@@ -12,7 +13,51 @@ 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} />
</>
})
})()
}
</div>
)
}

View File

@@ -8,7 +8,6 @@ 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"
@@ -25,7 +24,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 +74,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 +161,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

@@ -11,8 +11,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 +77,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())}
key={v.getId()}
recentChat={v} />
)

View File

@@ -350,14 +350,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

@@ -38,7 +38,7 @@ export default class User {
/* 用户名 */ username TEXT,
/* 昵称 */ nickname TEXT NOT NULL,
/* 头像, 可选 */ avatar_file_hash TEXT,
/* 对话列表 */ contacts_list TEXT NOT NULL,
/* 对话列表 */ favourite_chats TEXT NOT NULL,
/* 最近对话 */ recent_chats TEXT NOT NULL,
/* 设置 */ settings TEXT NOT NULL
);
@@ -62,7 +62,7 @@ export default class User {
username,
nickname,
avatar_file_hash,
contacts_list,
favourite_chats,
recent_chats,
settings
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(
@@ -142,17 +142,17 @@ export default class User {
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))
this.setAttr("favourite_chats", JSON.stringify(ls))
}
removeFavouriteChats(contacts: string[]) {
const ls = this.getFavouriteChats().filter((v) => !contacts.includes(v))
this.setAttr("contacts_list", JSON.stringify(ls))
this.setAttr("favourite_chats", JSON.stringify(ls))
}
getFavouriteChats() {
try {
return JSON.parse(this.bean.contacts_list) as string[]
return [...(JSON.parse(this.bean.favourite_chats) as string[]), ChatPrivate.findOrCreateForPrivate(this, this).bean.id]
} catch (e) {
console.log(chalk.yellow(`警告: 所有对话解析失败: ${(e as Error).message}`))
console.log(chalk.yellow(`警告: 收藏对话解析失败: ${(e as Error).message}`))
return []
}
}

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