diff --git a/client/index.ts b/client/index.ts index b385836..35ffd15 100644 --- a/client/index.ts +++ b/client/index.ts @@ -1,7 +1,6 @@ import 'mdui/mdui.css' import 'mdui' -import { $ } from "mdui/jq" -import { breakpoint, Dialog } from "mdui" +import { breakpoint } from "mdui" import * as React from 'react' import ReactDOM from 'react-dom/client' @@ -10,6 +9,7 @@ import './ui/custom-elements/chat-image.ts' import './ui/custom-elements/chat-video.ts' import './ui/custom-elements/chat-file.ts' import './ui/custom-elements/chat-text.ts' +import './ui/custom-elements/chat-mention.ts' import './ui/custom-elements/chat-text-container.ts' import App from './ui/App.tsx' diff --git a/client/ui/chat/ChatFragment.tsx b/client/ui/chat/ChatFragment.tsx index f40fb6e..9f17fa1 100644 --- a/client/ui/chat/ChatFragment.tsx +++ b/client/ui/chat/ChatFragment.tsx @@ -46,15 +46,17 @@ const sanitizeConfig = { "chat-file", 'chat-text', "chat-link", + 'chat-mention', ], ALLOWED_ATTR: [ 'underline', 'em', - 'src', 'alt', 'href', 'name', + 'user-id', + 'chat-id', ], } @@ -71,16 +73,23 @@ const markedInstance = new marked.Marked({ return `${escapeHTML(text)}` }, image({ text, href }) { - const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image' - if (/tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) { + const type = /^(Video|File|UserMention|ChatMention)=.*/.exec(text)?.[1] + const fileType = /^(Video|File)=.*/.exec(text)?.[1] || 'Image' + if (fileType != null && /tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) { const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]) return ({ Image: ``, Video: ``, File: ``, - })?.[type] || `` - } - return `` + })?.[fileType] || `` + } else + switch (type) { + case "UserMention": + return `` + case "ChatMention": + return `` + } + return `(不支持的附件语法: ![${text}](${href}))` }, } }) @@ -191,26 +200,28 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC let i = 1 let i2 = 0 const sendingFilesSnackbarId = setInterval(() => { - sendingFilesSnackbar.textContent = `上传第 ${i2}/${Object.keys(cachedFiles.current).length} 文件到 [${chatInfo.title}]... (${i}s)` + const len = Object.keys(cachedFiles.current).length + sendingFilesSnackbar.textContent = i2 == len ? `发送消息到 [${chatInfo.title}]... (${i}s)` : `上传第 ${i2}/${len} 文件到 [${chatInfo.title}]... (${i}s)` i++ }, 1000) + function endSendingSnack() { + clearTimeout(sendingFilesSnackbarId) + sendingFilesSnackbar.open = false + } try { let text = inputRef.current!.value if (text.trim() == '') return setIsMessageSending(true) for (const fileName of Object.keys(cachedFiles.current)) { if (text.indexOf(fileName) != -1) { - /* const re = await Client.invoke("Chat.uploadFile", { - token: data.access_token, - file_name: fileName, - target, - data: cachedFiles.current[fileName], - }, 5000) */ const re = await Client.uploadFileLikeApi( fileName, cachedFiles.current[fileName] ) - if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上传失败`)) return setIsMessageSending(false) + if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上传失败`)) { + endSendingSnack() + return setIsMessageSending(false) + } text = text.replaceAll('(' + fileName + ')', '(tws://file?hash=' + re.data!.file_hash as string + ')') i2++ } @@ -221,7 +232,10 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC target, text, }, 5000) - if (checkApiSuccessOrSncakbar(re, "发送失败")) return setIsMessageSending(false) + if (checkApiSuccessOrSncakbar(re, "发送失败")) { + endSendingSnack() + return setIsMessageSending(false) + } inputRef.current!.value = '' cachedFiles.current = {} } catch (e) { @@ -231,8 +245,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC }) } setIsMessageSending(false) - clearTimeout(sendingFilesSnackbarId) - sendingFilesSnackbar.open = false + endSendingSnack() } const attachFileInputRef = React.useRef(null) @@ -376,6 +389,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC if (!chatInfo.is_member) return const scrollTop = (e.target as HTMLDivElement).scrollTop if (scrollTop == 0 && !showLoadingMoreMessagesTip) { + setShowNoMoreMessagesTip(false) setShowLoadingMoreMessagesTip(true) await loadMore() setShowLoadingMoreMessagesTip(false) @@ -413,13 +427,21 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC (() => { let date = new Date(0) return messagesList.map((msg) => { - const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig) const lastDate = date date = new Date(msg.time) - const msgElement = msg.user_id == null ? {msg.text} :
: v.innerHTML)) diff --git a/client/ui/custom-elements/chat-mention.ts b/client/ui/custom-elements/chat-mention.ts index e69de29..97c6865 100644 --- a/client/ui/custom-elements/chat-mention.ts +++ b/client/ui/custom-elements/chat-mention.ts @@ -0,0 +1,62 @@ +import { $ } from 'mdui' +import DataCaches from "../../api/DataCaches.ts" +import { snackbar } from "../snackbar.ts" + +customElements.define('chat-mention', class extends HTMLElement { + declare span: HTMLSpanElement + static observedAttributes = ['user-id'] + constructor() { + super() + + this.attachShadow({ mode: 'open' }) + } + connectedCallback() { + const shadow = this.shadowRoot as ShadowRoot + + this.span = document.createElement('span') + this.span.style.whiteSpace = 'pre-wrap' + this.span.style.fontSynthesis = 'style weight' + this.span.style.color = 'rgb(var(--mdui-color-primary))' + shadow.appendChild(this.span) + + this.update() + } + attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) { + this.update() + } + async update() { + if (this.span == null) return + + const userId = $(this).attr('user-id') + const chatId = $(this).attr('chat-id') + const text = $(this).attr('text') + this.span.style.fontStyle = '' + if (chatId) { + const chat = await DataCaches.getChatInfo(chatId) + this.span.textContent = chat?.title + this.span.onclick = () => { + // deno-lint-ignore no-window + window.openChatInfoDialog(chat) + } + } else if (userId) { + const user = await DataCaches.getUserProfile(userId) + this.span.textContent = user?.nickname + this.span.onclick = () => { + // deno-lint-ignore no-window + window.openUserInfoDialog(user) + } + } + + text && (this.span.textContent = text) + if (!(userId || chatId)) { + this.span.textContent = "无效的提及" + this.span.style.fontStyle = 'italic' + this.span.onclick = () => { + snackbar({ + message: "该提及没有指定用户或者对话!", + placement: 'top', + }) + } + } + } +})