diff --git a/client/index.ts b/client/index.ts index 752bce3..0ca8208 100644 --- a/client/index.ts +++ b/client/index.ts @@ -9,6 +9,8 @@ import ReactDOM from 'react-dom/client' 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-text-container.ts' import App from './ui/App.tsx' import AppMobile from './ui/AppMobile.tsx' diff --git a/client/ui/chat/ChatFragment.tsx b/client/ui/chat/ChatFragment.tsx index 827e634..d13c85d 100644 --- a/client/ui/chat/ChatFragment.tsx +++ b/client/ui/chat/ChatFragment.tsx @@ -29,6 +29,7 @@ import PreferenceUpdater from "../preference/PreferenceUpdater.ts" import SystemMessage from "./SystemMessage.tsx" import JoinRequestsList from "./JoinRequestsList.tsx" import getUrlForFileByHash from "../../getUrlForFileByHash.ts" +import escapeHTML from "../../escapeHtml.ts" interface Args extends React.HTMLAttributes { target: string @@ -38,24 +39,47 @@ interface Args extends React.HTMLAttributes { openUserInfoDialog: (user: User | string) => void } +const sanitizeConfig = { + ALLOWED_TAGS: [ + "chat-image", + "chat-video", + "chat-file", + 'chat-text', + "chat-link", + ], + ALLOWED_ATTR: [ + 'underline', + 'em', + + 'src', + 'alt', + 'href', + 'name', + ], +} + const markedInstance = new marked.Marked({ renderer: { + text({ text }) { + console.log('普通文字', text) + return `${escapeHTML(text)}` + }, + em({ text }) { + console.log('斜体', text) + return `${escapeHTML(text)}` + }, heading({ tokens, depth: _depth }) { const text = this.parser.parseInline(tokens) - return `${text}` - }, - paragraph({ tokens }) { - const text = this.parser.parseInline(tokens) - return `${text}` + 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 url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]) return ({ - Image: ``, + Image: ``, Video: ``, - File: ``, + File: ``, })?.[type] || `` } return `` @@ -377,21 +401,7 @@ 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, { - ALLOWED_TAGS: [ - "chat-image", - "chat-video", - "chat-file", - "span", - "chat-link", - ], - ALLOWED_ATTR: [ - 'src', - 'alt', - 'href', - 'name', - ], - }).replaceAll('\n', '
') + const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig) const lastDate = date date = new Date(msg.time) diff --git a/client/ui/chat/Message.tsx b/client/ui/chat/Message.tsx index e6d3306..8100b7d 100644 --- a/client/ui/chat/Message.tsx +++ b/client/ui/chat/Message.tsx @@ -21,6 +21,34 @@ interface Args extends React.HTMLAttributes { openUserInfoDialog: (user: User | string) => void } +function prettyFlatParsedMessage(html: string) { + const elements = new DOMParser().parseFromString(html, 'text/html').body.children + let ls: Element[] = [] + let ret = '' + // 第一个元素时, 不会被聚合在一起 + let lastElementType = '' + function checkContinuousElement(tagName: string) { + if (lastElementType != tagName) { + if (lastElementType == 'chat-text') + ret += `${ls.map((v) => v.outerHTML).join('')}` + else + ret += ls.map((v) => v.outerHTML).join('') + ls = [] + } + } + for (const e of elements) { + console.log(e) + // 当出现非文本元素时, 将文本聚合在一起 + // 如果是其他类型, 虽然也执行聚合, 但是不会有外层包裹 + checkContinuousElement(e.nodeName.toLowerCase()) + ls.push(e) + lastElementType = e.nodeName.toLowerCase() + } + // 最后将剩余的转换 + checkContinuousElement('') + return ret +} + export default function Message({ userId, rawData, renderHTML, message, openUserInfoDialog, ...props }: Args) { const isAtRight = Client.myUserProfile?.id == userId @@ -44,15 +72,15 @@ export default function Message({ userId, rawData, renderHTML, message, openUser const [isDropDownOpen, setDropDownOpen] = React.useState(false) - const [isUsingFullDisplay, setIsUsingFullDisplay] = React.useState(false) + /* const [isUsingFullDisplay, setIsUsingFullDisplay] = React.useState(false) */ - React.useEffect(() => { + /* React.useEffect(() => { const text = $(dropDownRef.current as HTMLElement).find('#msg').text().trim() setIsUsingFullDisplay(text == '' || ( rawData.split("tws:\/\/file\?hash=").length == 2 && /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim()) )) - }, [renderHTML]) + }, [renderHTML]) */ return (
{ @@ -138,10 +167,12 @@ export default function Message({ userId, rawData, renderHTML, message, openUser slot="trigger" id="msg" style={{ - fontSize: "94%" + fontSize: "94%", + display: 'flex', + flexDirection: 'column', }} dangerouslySetInnerHTML={{ - __html: renderHTML + __html: prettyFlatParsedMessage(renderHTML) }} /> { e.stopPropagation() diff --git a/client/ui/custom-elements/chat-text-container.ts b/client/ui/custom-elements/chat-text-container.ts new file mode 100644 index 0000000..05ce0a9 --- /dev/null +++ b/client/ui/custom-elements/chat-text-container.ts @@ -0,0 +1,16 @@ +customElements.define('chat-text-container', class extends HTMLElement { + declare container: HTMLDivElement + constructor() { + super() + + this.attachShadow({ mode: 'open' }) + } + connectedCallback() { + const shadow = this.shadowRoot as ShadowRoot + + this.container = document.createElement('div') + this.container.style.padding = '13px' + shadow.appendChild(this.container) + this.container.innerHTML = this.innerHTML + } +}) diff --git a/client/ui/custom-elements/chat-text.ts b/client/ui/custom-elements/chat-text.ts new file mode 100644 index 0000000..9f24610 --- /dev/null +++ b/client/ui/custom-elements/chat-text.ts @@ -0,0 +1,29 @@ +import { $ } from 'mdui' + +customElements.define('chat-text', class extends HTMLElement { + declare span: HTMLSpanElement + static observedAttributes = ['underline', 'em'] + 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' + shadow.appendChild(this.span) + + this.update() + } + attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) { + this.update() + } + update() { + this.span.textContent = this.textContent + this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : '' + this.span.style.fontStyle = $(this).attr('em') ? 'italic' : '' + } +})