import { Tab, TextField } from "mdui" import { $ } from "mdui/jq" import useEventListener from "../useEventListener.ts" import Element_Message from "./Message.tsx" import MessageContainer from "./MessageContainer.tsx" import * as React from 'react' import Client from "../../api/Client.ts" import Message from "../../api/client_data/Message.ts" import Chat from "../../api/client_data/Chat.ts" import data from "../../Data.ts" import { checkApiSuccessOrSncakbar, snackbar } from "../snackbar.ts" import useAsyncEffect from "../useAsyncEffect.ts" import * as marked from 'marked' import DOMPurify from 'dompurify' import randomUUID from "../../randomUUID.ts" interface Args extends React.HTMLAttributes { target: string showReturnButton?: boolean onReturnButtonClicked?: () => void } const markedInstance = new marked.Marked({ renderer: { heading({ tokens, depth: _depth }) { const text = this.parser.parseInline(tokens) return `${text}` }, paragraph({ tokens }) { const text = this.parser.parseInline(tokens) return `${text}` }, image({ text, href }) { const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image' if (/uploaded_files\/[A-Za-z0-9]+$/.test(href)) { return ({ Image: ``, Video: ``, File: ``, })?.[type] || `` } return `` }, } }) export default function ChatFragment({ target, showReturnButton, onReturnButtonClicked, ...props }: Args) { const [messagesList, setMessagesList] = React.useState([] as Message[]) const [chatInfo, setChatInfo] = React.useState({ title: '加載中...' } as Chat) const [tabItemSelected, setTabItemSelected] = React.useState('None') const tabRef = React.useRef(null) const chatPanelRef = React.useRef(null) useEventListener(tabRef, 'change', () => { tabRef.current != null && setTabItemSelected(tabRef.current!.value as string) }) useAsyncEffect(async () => { const re = await Client.invoke('Chat.getInfo', { token: data.access_token, target: target, }) if (re.code != 200) return target != '' && checkApiSuccessOrSncakbar(re, "對話錯誤") setChatInfo(re.data as Chat) await loadMore() setTabItemSelected("Chat") setTimeout(() => { chatPanelRef.current!.scrollTo({ top: 10000000000, behavior: "smooth", }) }, 100) }, [target]) const page = React.useRef(0) async function loadMore() { const re = await Client.invoke("Chat.getMessageHistory", { token: data.access_token, target, page: page.current, }) if (checkApiSuccessOrSncakbar(re, "拉取歷史記錄失敗")) return const returnMsgs = (re.data!.messages as Message[]).reverse() page.current++ if (returnMsgs.length == 0) { setShowNoMoreMessagesTip(true) setTimeout(() => setShowNoMoreMessagesTip(false), 1000) return } const oldest = messagesList[0] setMessagesList(returnMsgs.concat(messagesList)) setTimeout(() => chatPanelRef.current!.scrollTo({ top: $(`#chat_${target}_message_${oldest.id}`).get(0).offsetTop, behavior: 'smooth' }), 100) } React.useEffect(() => { interface OnMessageData { chat: string msg: Message } function callback(data: unknown) { const { chat, msg } = (data as OnMessageData) if (target == chat) { setMessagesList(messagesList.concat([msg])) if ((chatPanelRef.current!.scrollHeight - chatPanelRef.current!.scrollTop - chatPanelRef.current!.clientHeight) < 130) setTimeout(() => chatPanelRef.current!.scrollTo({ top: 10000000000, behavior: "smooth", }), 100) } } Client.on('Client.onMessage', callback) return () => { Client.off('Client.onMessage', callback) } }) const inputRef = React.useRef(null) const [showLoadingMoreMessagesTip, setShowLoadingMoreMessagesTip] = React.useState(false) const [showNoMoreMessagesTip, setShowNoMoreMessagesTip] = React.useState(false) const [isMessageSending, setIsMessageSending] = React.useState(false) const cachedFiles = React.useRef({} as { [fileName: string]: ArrayBuffer }) const cachedFileNamesCount = React.useRef({} as { [fileName: string]: number }) async function sendMessage() { 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) if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上傳失敗`)) return setIsMessageSending(false) text = text.replaceAll('(' + fileName + ')', '(' + re.data!.file_path as string + ')') } } const re = await Client.invoke("Chat.sendMessage", { token: data.access_token, target, text, }, 5000) if (checkApiSuccessOrSncakbar(re, "發送失敗")) return setIsMessageSending(false) inputRef.current!.value = '' cachedFiles.current = {} } catch (e) { snackbar({ message: '發送失敗: ' + (e as Error).message, placement: 'top', }) } setIsMessageSending(false) } const attachFileInputRef = React.useRef(null) function insertText(text: string) { const input = inputRef.current!.shadowRoot!.querySelector('[part=input]') as HTMLTextAreaElement inputRef.current!.value = input.value!.substring(0, input.selectionStart as number) + text + input.value!.substring(input.selectionEnd as number, input.value.length) } async function addFile(type: string, name_: string, data: Blob | Response) { let name = name_ while (cachedFiles.current[name] != null) { name = name_ + '_' + cachedFileNamesCount.current[name] cachedFileNamesCount.current[name]++ } cachedFiles.current[name] = await data.arrayBuffer() cachedFileNamesCount.current[name] = 1 if (type.startsWith('image/')) insertText(`![圖片](${name})`) else if (type.startsWith('video/')) insertText(`![Video=${name}](${name})`) else insertText(`![File=${name}](${name})`) } useEventListener(attachFileInputRef, 'change', (_e) => { const files = attachFileInputRef.current!.files as unknown as File[] if (files?.length == 0) return for (const file of files) { addFile(file.type, file.name, file) } }) return (
{ showReturnButton && } { chatInfo.title } 設定 { const scrollTop = (e.target as HTMLDivElement).scrollTop if (scrollTop == 0 && !showLoadingMoreMessagesTip) { setShowLoadingMoreMessagesTip(true) await loadMore() setShowLoadingMoreMessagesTip(false) } }}>
加載中...
沒有更多消息啦~
{ (() => { 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 lastDate = date date = new Date(msg.time) const msgElement = return ( <> { (date.getMinutes() != lastDate.getMinutes() || date.getDate() != lastDate.getDate() || date.getMonth() != lastDate.getMonth() || date.getFullYear() != lastDate.getFullYear()) &&
{ (date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}年` : '') + `${date.getMonth() + 1}月` + `${date.getDate()}日` + ` ${date.getHours()}:${date.getMinutes()}` }
} { msgElement } ) }) })() }
{ // 输入框 }
{ function getFileNameOrRandom(urlString: string) { const url = new URL(urlString) let filename = url.pathname.substring(url.pathname.lastIndexOf('/') + 1).trim() if (filename == '') filename = 'file_' + randomUUID() return filename } if (e.dataTransfer.items.length > 0) { // 基于当前的实现, 浏览器不会读取文件的字节流来确定其媒体类型, 其根据文件扩展名进行假设 // https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/type for (const item of e.dataTransfer.items) { if (item.type == 'text/uri-list') { item.getAsString(async (url) => { try { // 即便是 no-cors 還是殘廢, 因此暫時沒有什麽想法 const re = await fetch(url) const type = re.headers.get("Content-Type") if (type?.startsWith("image/")) addFile(type as string, getFileNameOrRandom(url), re) } catch (e) { snackbar({ message: '無法解析連結: ' + (e as Error).message, placement: 'top', }) } }) } else if (item.kind == 'file') { e.preventDefault() const file = item.getAsFile() as File addFile(item.type, file.name, file) } } } }}> { if (inputRef.current?.value.trim() == '') cachedFiles.current = {} }} onKeyDown={(event) => { if (event.ctrlKey && event.key == 'Enter') sendMessage() }} onPaste={(event) => { for (const item of event.clipboardData.items) { if (item.kind == 'file') { event.preventDefault() const file = item.getAsFile() as File addFile(item.type, file.name, file) } } }} style={{ marginRight: '10px', marginTop: '3px', marginBottom: '3px', }}> { attachFileInputRef.current!.click() }}> sendMessage()} loading={isMessageSending}>
Work in progress...
) }