diff --git a/client/ui/chat-elements/chat-mention.ts b/client/ui/chat-elements/chat-mention.ts index c0de054..fb30a0c 100644 --- a/client/ui/chat-elements/chat-mention.ts +++ b/client/ui/chat-elements/chat-mention.ts @@ -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) diff --git a/client/ui/chat-fragment/ChatFragment.tsx b/client/ui/chat-fragment/ChatFragment.tsx index 800c5bf..523db21 100644 --- a/client/ui/chat-fragment/ChatFragment.tsx +++ b/client/ui/chat-fragment/ChatFragment.tsx @@ -44,7 +44,10 @@ export default function ChatFragment({ const inputRef = React.useRef() const chatPagePanelRef = React.useRef() - + async function performSendMessage() { + await chatInfo.sendMessageOrThrow(inputRef.current!.value) + inputRef.current!.value = '' + } return (
{ 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({ }}> { - // 发送消息 - await chatInfo.sendMessageOrThrow(inputRef.current!.value) - inputRef.current!.value = '' - }}> + }} onClick={performSendMessage}>
diff --git a/client/ui/chat-fragment/ChatMessage.tsx b/client/ui/chat-fragment/ChatMessage.tsx index 7babdfc..72cfae0 100644 --- a/client/ui/chat-fragment/ChatMessage.tsx +++ b/client/ui/chat-fragment/ChatMessage.tsx @@ -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 `${escapeHTML(html)}` + 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 += `${ls.map((v) => v.outerHTML).join('')}` + } 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() + const [isMessageDropDownOpen, setMessageDropDownOpen] = React.useState(false) + useEventListener(messageDropDownRef, 'closed', () => { + setMessageDropDownOpen(false) + }) + + const avatarDropDownRef = React.useRef() + 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(null) + React.useEffect(() => { + messageInnerRef.current!.innerHTML = prettyFlatParsedMessage(DOMPurify.sanitize(message.parseWithTransformers({ + attachment({ fileType, attachment }) { + const url = getClient().getUrlForFileByHash(attachment.getFileHash()) + return ({ + Image: ``, + Video: ``, + File: ``, + })?.[fileType] + }, + mention({ mentionType, mention }) { + switch (mentionType) { + case "UserMention": + return `[对话提及]` + case "ChatMention": + return `[对话提及]` + } + }, + }), sanitizeConfig)) + + // 没有办法的办法 (笑) + // 姐姐, 谁让您不是 React 组件呢 + messageInnerRef.current!.querySelectorAll('chat-mention').forEach((v) => { + const e = v as ChatMentionElement + e.openChatInfo = AppState.openChatInfo + e.openUserInfo = AppState.openUserInfo + }) + }, [message]) + + return ( +
{ + 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 && +
+ { + // 发送者昵称(左) + isAtRight && + {nickName} + + } + { + // 发送者头像 + } + + { + if (isMobileUI()) return + e.preventDefault() + e.stopPropagation() + setAvatarDropDownOpen(!isAvatarDropDownOpen) + }} + onClick={(e) => { + e.stopPropagation() + AppState.openUserInfo(message.getUserId()!) + }} /> + + {avatarMenuItems} + + + { + // 发送者昵称(右) + !isAtRight && + {nickName} + + } +
+ } + + + + { + e.stopPropagation() + setMessageDropDownOpen(false) + }}> + {messageMenuItems} + + + + +
+ ) } diff --git a/client/ui/chat-fragment/ChatMessageContainer.tsx b/client/ui/chat-fragment/ChatMessageContainer.tsx index 6054dd6..89814bc 100644 --- a/client/ui/chat-fragment/ChatMessageContainer.tsx +++ b/client/ui/chat-fragment/ChatMessageContainer.tsx @@ -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 && +
+ { + (date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}年` : '') + + `${date.getMonth() + 1}月` + + `${date.getDate()}日` + + ` ${timeAddZeroPrefix(date.getHours())}:${timeAddZeroPrefix(date.getMinutes())}` + } +
+
+ } + + + }) + })() + }
) } diff --git a/client/ui/main-page/FavouriteChatsList.tsx b/client/ui/main-page/FavouriteChatsList.tsx index f0835db..80fcc55 100644 --- a/client/ui/main-page/FavouriteChatsList.tsx +++ b/client/ui/main-page/FavouriteChatsList.tsx @@ -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({}) - 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 DialogState.openAddFavouriteChat()}>添加收藏 + }} icon="person_add" onClick={() => AppState.openAddFavouriteChat()}>添加收藏 shared.functions_lazy.current.updateFavouriteChats()}>刷新列表 @@ -162,7 +161,7 @@ export default function FavouriteChatsList({ ...props }: React.HTMLAttributes diff --git a/client/ui/main-page/RecentChatsList.tsx b/client/ui/main-page/RecentChatsList.tsx index 678d597..154e081 100644 --- a/client/ui/main-page/RecentChatsList.tsx +++ b/client/ui/main-page/RecentChatsList.tsx @@ -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) { + 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 {}} + onClick={() => AppState.openChat(v.getId())} key={v.getId()} recentChat={v} /> )