Compare commits
6 Commits
f0ca0fbbd4
...
86ace28066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ace28066 | ||
|
|
b46449a6e4 | ||
|
|
19b2fce904 | ||
|
|
a7df2c689a | ||
|
|
6ce8acdb2e | ||
|
|
149f003175 |
@@ -137,7 +137,7 @@ export default class LingChairClient {
|
|||||||
password: string,
|
password: string,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
this.registerOrThrow(args)
|
await this.registerOrThrow(args)
|
||||||
return true
|
return true
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
5
client/escapeHtml.ts
Normal file
5
client/escapeHtml.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default function escapeHTML(str: string) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = str
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import './ui/custom-elements/chat-image.ts'
|
import './ui/custom-elements/chat-image.ts'
|
||||||
import './ui/custom-elements/chat-video.ts'
|
import './ui/custom-elements/chat-video.ts'
|
||||||
import './ui/custom-elements/chat-file.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 App from './ui/App.tsx'
|
||||||
import AppMobile from './ui/AppMobile.tsx'
|
import AppMobile from './ui/AppMobile.tsx'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import PreferenceUpdater from "../preference/PreferenceUpdater.ts"
|
|||||||
import SystemMessage from "./SystemMessage.tsx"
|
import SystemMessage from "./SystemMessage.tsx"
|
||||||
import JoinRequestsList from "./JoinRequestsList.tsx"
|
import JoinRequestsList from "./JoinRequestsList.tsx"
|
||||||
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
|
import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
|
||||||
|
import escapeHTML from "../../escapeHtml.ts"
|
||||||
|
|
||||||
interface Args extends React.HTMLAttributes<HTMLElement> {
|
interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||||
target: string
|
target: string
|
||||||
@@ -38,24 +39,47 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
|
|||||||
openUserInfoDialog: (user: User | string) => void
|
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({
|
const markedInstance = new marked.Marked({
|
||||||
renderer: {
|
renderer: {
|
||||||
|
text({ text }) {
|
||||||
|
console.log('普通文字', text)
|
||||||
|
return `<chat-text>${escapeHTML(text)}</chat-text>`
|
||||||
|
},
|
||||||
|
em({ text }) {
|
||||||
|
console.log('斜体', text)
|
||||||
|
return `<chat-text em="true">${escapeHTML(text)}</chat-text>`
|
||||||
|
},
|
||||||
heading({ tokens, depth: _depth }) {
|
heading({ tokens, depth: _depth }) {
|
||||||
const text = this.parser.parseInline(tokens)
|
const text = this.parser.parseInline(tokens)
|
||||||
return `<span>${text}</span>`
|
return `<chat-text>${escapeHTML(text)}</chat-text>`
|
||||||
},
|
|
||||||
paragraph({ tokens }) {
|
|
||||||
const text = this.parser.parseInline(tokens)
|
|
||||||
return `<span>${text}</span>`
|
|
||||||
},
|
},
|
||||||
image({ text, href }) {
|
image({ text, href }) {
|
||||||
const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
|
const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
|
||||||
if (/tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
|
if (/tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
|
||||||
const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1])
|
const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1])
|
||||||
return ({
|
return ({
|
||||||
Image: `<chat-image src="${url}" alt="${text}"></chat-image>`,
|
Image: `<chat-image src="${url}" alt="${escapeHTML(text)}"></chat-image>`,
|
||||||
Video: `<chat-video src="${url}"></chat-video>`,
|
Video: `<chat-video src="${url}"></chat-video>`,
|
||||||
File: `<chat-file href="${url}" name="${/^Video|File=(.*)/.exec(text)?.[1] || 'Unnamed file'}"></chat-file>`,
|
File: `<chat-file href="${url}" name="${escapeHTML(/^Video|File=(.*)/.exec(text)?.[1] || 'Unnamed file')}"></chat-file>`,
|
||||||
})?.[type] || ``
|
})?.[type] || ``
|
||||||
}
|
}
|
||||||
return ``
|
return ``
|
||||||
@@ -377,21 +401,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
|||||||
(() => {
|
(() => {
|
||||||
let date = new Date(0)
|
let date = new Date(0)
|
||||||
return messagesList.map((msg) => {
|
return messagesList.map((msg) => {
|
||||||
const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
|
const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig)
|
||||||
ALLOWED_TAGS: [
|
|
||||||
"chat-image",
|
|
||||||
"chat-video",
|
|
||||||
"chat-file",
|
|
||||||
"span",
|
|
||||||
"chat-link",
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [
|
|
||||||
'src',
|
|
||||||
'alt',
|
|
||||||
'href',
|
|
||||||
'name',
|
|
||||||
],
|
|
||||||
}).replaceAll('\n', '<br>')
|
|
||||||
const lastDate = date
|
const lastDate = date
|
||||||
date = new Date(msg.time)
|
date = new Date(msg.time)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,34 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
|
|||||||
openUserInfoDialog: (user: User | string) => void
|
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 += `<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) {
|
||||||
|
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) {
|
export default function Message({ userId, rawData, renderHTML, message, openUserInfoDialog, ...props }: Args) {
|
||||||
const isAtRight = Client.myUserProfile?.id == userId
|
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 [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()
|
const text = $(dropDownRef.current as HTMLElement).find('#msg').text().trim()
|
||||||
setIsUsingFullDisplay(text == '' || (
|
setIsUsingFullDisplay(text == '' || (
|
||||||
rawData.split("tws:\/\/file\?hash=").length == 2
|
rawData.split("tws:\/\/file\?hash=").length == 2
|
||||||
&& /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim())
|
&& /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim())
|
||||||
))
|
))
|
||||||
}, [renderHTML])
|
}, [renderHTML]) */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -122,10 +150,11 @@ export default function Message({ userId, rawData, renderHTML, message, openUser
|
|||||||
minWidth: "0%",
|
minWidth: "0%",
|
||||||
[isAtRight ? "marginRight" : "marginLeft"]: "55px",
|
[isAtRight ? "marginRight" : "marginLeft"]: "55px",
|
||||||
marginTop: "-5px",
|
marginTop: "-5px",
|
||||||
padding: isUsingFullDisplay ? undefined : "13px",
|
|
||||||
paddingTop: isUsingFullDisplay ? undefined : "14px",
|
|
||||||
alignSelf: isAtRight ? "flex-end" : "flex-start",
|
alignSelf: isAtRight ? "flex-end" : "flex-start",
|
||||||
backgroundColor: isUsingFullDisplay ? "inherit" : undefined
|
// boxShadow: isUsingFullDisplay ? 'inherit' : 'var(--mdui-elevation-level1)',
|
||||||
|
// padding: isUsingFullDisplay ? undefined : "13px",
|
||||||
|
// paddingTop: isUsingFullDisplay ? undefined : "14px",
|
||||||
|
// backgroundColor: isUsingFullDisplay ? "inherit" : undefined
|
||||||
}}>
|
}}>
|
||||||
<mdui-dialog close-on-overlay-click close-on-esc ref={messageJsonDialogRef}>
|
<mdui-dialog close-on-overlay-click close-on-esc ref={messageJsonDialogRef}>
|
||||||
{
|
{
|
||||||
@@ -138,10 +167,12 @@ export default function Message({ userId, rawData, renderHTML, message, openUser
|
|||||||
slot="trigger"
|
slot="trigger"
|
||||||
id="msg"
|
id="msg"
|
||||||
style={{
|
style={{
|
||||||
fontSize: "94%"
|
fontSize: "94%",
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderHTML
|
__html: prettyFlatParsedMessage(renderHTML)
|
||||||
}} />
|
}} />
|
||||||
<mdui-menu onClick={(e) => {
|
<mdui-menu onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ customElements.define('chat-file', class extends HTMLElement {
|
|||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const e = new DOMParser().parseFromString(`
|
const e = new DOMParser().parseFromString(`
|
||||||
<a style="width: 100%;height: 100%;">
|
<a style="width: 100%;height: 100%;">
|
||||||
<mdui-card clickable style="display: flex;align-items: center;">
|
<mdui-card clickable style="display: flex;align-items: center;box-shadow: inherit;border-radius: inherit;">
|
||||||
<mdui-icon name="insert_drive_file" style="margin: 13px;font-size: 34px;"></mdui-icon>
|
<mdui-icon name="insert_drive_file" style="margin: 13px;font-size: 34px;"></mdui-icon>
|
||||||
<span style="margin-right: 13px; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
|
<span style="margin-right: 13px; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
|
||||||
</mdui-card>
|
</mdui-card>
|
||||||
|
|||||||
@@ -1,39 +1,59 @@
|
|||||||
import openImageViewer from "../openImageViewer.ts"
|
import openImageViewer from "../openImageViewer.ts"
|
||||||
import { snackbar } from "../snackbar.ts"
|
|
||||||
|
|
||||||
import { $ } from 'mdui/jq'
|
import { $ } from 'mdui/jq'
|
||||||
|
|
||||||
|
|
||||||
customElements.define('chat-image', class extends HTMLElement {
|
customElements.define('chat-image', class extends HTMLElement {
|
||||||
|
static observedAttributes = ['src', 'show-error']
|
||||||
|
declare img: HTMLImageElement
|
||||||
|
declare error: HTMLElement
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
if (this.img == null) return
|
||||||
|
|
||||||
|
this.img.src = $(this).attr('src') as string
|
||||||
|
|
||||||
|
const error = $(this).attr('show-error') == 'true'
|
||||||
|
this.img.style.display = error ? 'none' : 'block'
|
||||||
|
this.error.style.display = error ? '' : 'none'
|
||||||
|
}
|
||||||
|
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||||
|
this.update()
|
||||||
}
|
}
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.style.display = 'block'
|
this.img = new Image()
|
||||||
const e = new Image()
|
this.img.style.width = '100%'
|
||||||
e.style.maxWidth = "400px"
|
this.img.style.maxHeight = "300px"
|
||||||
e.style.maxHeight = "300px"
|
this.img.style.objectFit = 'cover'
|
||||||
e.style.marginTop = '5px'
|
// this.img.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
||||||
e.style.marginBottom = '5px'
|
this.shadowRoot!.appendChild(this.img)
|
||||||
e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
|
||||||
e.alt = $(this).attr('alt') || ""
|
this.error = new DOMParser().parseFromString(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`, 'text/html').body.firstChild as HTMLElement
|
||||||
e.onerror = () => {
|
this.shadowRoot!.appendChild(this.error)
|
||||||
const src = $(this).attr('src')
|
|
||||||
$(this).html(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`)
|
this.img.addEventListener('error', () => {
|
||||||
$(this).attr('alt', '无法加载: ' + $(this).attr('alt'))
|
$(this).attr('show-error', 'true')
|
||||||
$(this).on('click', () => {
|
})
|
||||||
snackbar({
|
this.error.addEventListener('click', (event) => {
|
||||||
message: `图片 (${src}) 无法加载!`,
|
event.stopPropagation()
|
||||||
placement: 'top'
|
const img = this.img
|
||||||
})
|
this.img = new Image()
|
||||||
})
|
this.img.style.width = '100%'
|
||||||
}
|
this.img.style.maxHeight = "300px"
|
||||||
e.src = $(this).attr('src') as string
|
this.img.style.objectFit = 'cover'
|
||||||
e.onclick = (event) => {
|
this.shadowRoot!.replaceChild(img, this.img)
|
||||||
|
$(this).attr('show-error', undefined)
|
||||||
|
})
|
||||||
|
this.img.addEventListener('click', (event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
openImageViewer($(this).attr('src') as string)
|
openImageViewer($(this).attr('src') as string)
|
||||||
}
|
})
|
||||||
this.appendChild(e)
|
|
||||||
|
this.update()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
16
client/ui/custom-elements/chat-text-container.ts
Normal file
16
client/ui/custom-elements/chat-text-container.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
29
client/ui/custom-elements/chat-text.ts
Normal file
29
client/ui/custom-elements/chat-text.ts
Normal file
@@ -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' : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import { $ } from 'mdui/jq'
|
import { $ } from 'mdui/jq'
|
||||||
|
|
||||||
customElements.define('chat-video', class extends HTMLElement {
|
customElements.define('chat-video', class extends HTMLElement {
|
||||||
|
static observedAttributes = ['src']
|
||||||
|
declare video: HTMLVideoElement
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
}
|
||||||
|
update() {
|
||||||
|
if (this.video == null) return
|
||||||
|
|
||||||
|
this.video.src = $(this).attr('src') as string
|
||||||
|
}
|
||||||
|
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
|
||||||
|
this.update()
|
||||||
}
|
}
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.style.display = 'block'
|
this.video = new DOMParser().parseFromString(`<video controls></video>`, 'text/html').body.firstChild as HTMLVideoElement
|
||||||
const e = new DOMParser().parseFromString(`<video controls></video>`, 'text/html').body.firstChild as HTMLVideoElement
|
this.video.style.maxWidth = "400px"
|
||||||
e.style.maxWidth = "400px"
|
this.video.style.maxHeight = "300px"
|
||||||
e.style.maxHeight = "300px"
|
this.video.style.width = "100%"
|
||||||
e.style.width = "100%"
|
this.video.style.height = "100%"
|
||||||
e.style.height = "100%"
|
this.video.style.display = 'block'
|
||||||
e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
// e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
||||||
e.src = $(this).attr('src') as string
|
|
||||||
e.onclick = (e) => e.stopPropagation()
|
this.video.onclick = (e) => e.stopPropagation()
|
||||||
this.appendChild(e)
|
this.shadowRoot!.appendChild(this.video)
|
||||||
|
this.update()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ export default defineConfig({
|
|||||||
outDir: "." + config.data_path + '/page_compiled',
|
outDir: "." + config.data_path + '/page_compiled',
|
||||||
minify: 'terser',
|
minify: 'terser',
|
||||||
cssMinify: 'lightningcss',
|
cssMinify: 'lightningcss',
|
||||||
terserOptions:{
|
|
||||||
compress:{
|
|
||||||
drop_console:true,
|
|
||||||
drop_debugger:true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user