import { Tab, Tabs, 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" import EventBus from "../../EventBus.ts" import User from "../../api/client_data/User.ts" import PreferenceLayout from '../preference/PreferenceLayout.tsx' import PreferenceHeader from '../preference/PreferenceHeader.tsx' import PreferenceStore from '../preference/PreferenceStore.ts' import SwitchPreference from '../preference/SwitchPreference.tsx' import SelectPreference from '../preference/SelectPreference.tsx' import TextFieldPreference from '../preference/TextFieldPreference.tsx' import Preference from '../preference/Preference.tsx' import GroupSettings from "../../api/client_data/GroupSettings.ts" 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" import GroupMembersList from "./GroupMembersList.tsx" import isMobileUI from "../isMobileUI.ts" interface Args extends React.HTMLAttributes { target: string showReturnButton?: boolean openChatInfoDialog: (chat: Chat) => void onReturnButtonClicked?: () => void openUserInfoDialog: (user: User | string) => void } const sanitizeConfig = { ALLOWED_TAGS: [ "chat-image", "chat-video", "chat-file", 'chat-text', "chat-link", 'chat-mention', ], ALLOWED_ATTR: [ 'underline', 'em', 'src', 'alt', 'href', 'name', 'user-id', 'chat-id', ], } const markedInstance = new marked.Marked({ renderer: { text({ text }) { return `${escapeHTML(text)}` }, em({ text }) { return `${escapeHTML(text)}` }, heading({ tokens, depth: _depth }) { const text = this.parser.parseInline(tokens) return `${escapeHTML(text)}` }, image({ text, 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: ``, })?.[fileType] || `` } else switch (type) { case "UserMention": return `PH` case "ChatMention": return `PH` } return `${escapeHTML(`[无效数据 (<${text}>=${href})]`)}` }, } }) interface MduiTabFitSizeArgs extends React.HTMLAttributes { value: string } function MduiTabFitSize({ children, ...props }: MduiTabFitSizeArgs) { return {children} } export default function ChatFragment({ target, showReturnButton, onReturnButtonClicked, openChatInfoDialog, openUserInfoDialog, ...props }: Args) { const [messagesList, setMessagesList] = React.useState([] as Message[]) const [chatInfo, setChatInfo] = React.useState({ title: '加载中...', is_member: true, is_admin: true, } 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) }) const containerTabRef = React.useRef(null) React.useEffect(() => { $(containerTabRef.current!.shadowRoot).append(``) $(tabRef.current!.shadowRoot).append(``) ; (!isMobileUI()) && $(tabRef.current!.shadowRoot).append(``) }, [target]) async function getChatInfoAndInit() { setMessagesList([]) page.current = 0 const re = await Client.invoke('Chat.getInfo', { token: data.access_token, target: target, }) if (re.code != 200) return target != '' && checkApiSuccessOrSncakbar(re, "获取对话信息失败") const chatInfo = re.data as Chat setChatInfo(chatInfo) if (chatInfo.is_member) await loadMore() setTabItemSelected(chatInfo.is_member ? "Chat" : "RequestJoin") if (re.data!.type == 'group') { groupPreferenceStore.setState(chatInfo.settings as GroupSettings) } setTimeout(() => { chatPanelRef.current!.scrollTo({ top: 10000000000, behavior: "smooth", }) }, 500) } useAsyncEffect(getChatInfoAndInit, [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)) oldest && setTimeout(() => chatPanelRef.current!.scrollTo({ top: $(`#chat_${target}_message_${oldest.id}`).get(0).offsetTop }), 200) } 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() { let text = inputRef.current!.value if (text.trim() == '') return const sendingFilesSnackbar = snackbar({ message: `发送消息到 [${chatInfo.title}]...`, placement: 'top', autoCloseDelay: 0, }) let i = 1 let i2 = 0 const sendingFilesSnackbarId = setInterval(() => { const len = Object.keys(cachedFiles.current).filter((fileName) => text.indexOf(fileName)).length sendingFilesSnackbar.textContent = i2 == len ? `发送消息到 [${chatInfo.title}]... (${i}s)` : `上传第 ${i2}/${len} 文件到 [${chatInfo.title}]... (${i}s)` i++ }, 1000) function endSendingSnack() { clearTimeout(sendingFilesSnackbarId) sendingFilesSnackbar.open = false } Client.socket?.once('disconnect', () => endSendingSnack()) try { setIsMessageSending(true) for (const fileName of Object.keys(cachedFiles.current)) { if (text.indexOf(fileName) != -1) { const re = await Client.uploadFileLikeApi( fileName, cachedFiles.current[fileName] ) if (checkApiSuccessOrSncakbar(re, `文件[${fileName}] 上传失败`)) { endSendingSnack() return setIsMessageSending(false) } text = text.replaceAll('(' + fileName + ')', '(tws://file?hash=' + re.data!.file_hash as string + ')') i2++ } } const re = await Client.invoke("Chat.sendMessage", { token: data.access_token, target, text, }, 5000) if (checkApiSuccessOrSncakbar(re, "发送失败")) { endSendingSnack() return setIsMessageSending(false) } inputRef.current!.value = '' cachedFiles.current = {} } catch (e) { snackbar({ message: '发送失败: ' + (e as Error).message, placement: 'top', }) } setIsMessageSending(false) endSendingSnack() } const attachFileInputRef = React.useRef(null) const uploadChatAvatarRef = 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) } attachFileInputRef.current!.value = '' }) useEventListener(uploadChatAvatarRef, 'change', async (_e) => { const file = uploadChatAvatarRef.current!.files?.[0] as File if (file == null) return let re = await Client.uploadFileLikeApi( 'avatar', file ) if (checkApiSuccessOrSncakbar(re, "上传失败")) return const hash = re.data!.file_hash re = await Client.invoke("Chat.setAvatar", { token: data.access_token, target: target, file_hash: hash, }) uploadChatAvatarRef.current!.value = '' if (checkApiSuccessOrSncakbar(re, "修改失败")) return snackbar({ message: "修改成功 (刷新页面以更新)", placement: "top", }) }) const groupPreferenceStore = new PreferenceStore() groupPreferenceStore.setOnUpdate(async (value, oldvalue) => { const re = await Client.invoke("Chat.updateSettings", { token: data.access_token, target, settings: value, }) if (checkApiSuccessOrSncakbar(re, "更新设定失败")) return groupPreferenceStore.setState(oldvalue) }) return (
{ showReturnButton && } { chatInfo.is_member ? <> {chatInfo.title} {chatInfo.type == 'group' && chatInfo.is_admin && 加入请求} {chatInfo.type == 'group' && 群组成员} : {chatInfo.title} } {chatInfo.type == 'group' && 设置}
{ page.current = 0 getChatInfoAndInit() }} style={{ alignSelf: 'center', marginLeft: '5px', marginRight: '5px', }}> openChatInfoDialog(chatInfo)} style={{ alignSelf: 'center', marginLeft: '5px', marginRight: '5px', }}>
{ const re = await Client.invoke("Chat.sendJoinRequest", { token: data.access_token, target: target, }) if (re.code != 200) return checkApiSuccessOrSncakbar(re, "发送加入请求失败") snackbar({ message: '发送成功!', placement: 'top', }) }}>请求加入对话
{ if (!chatInfo.is_member) return const scrollTop = (e.target as HTMLDivElement).scrollTop if (scrollTop == 0 && !showLoadingMoreMessagesTip) { setShowNoMoreMessagesTip(false) setShowLoadingMoreMessagesTip(true) await loadMore() setShowLoadingMoreMessagesTip(false) } }}>
加载中...
沒有更多消息啦~
{ (() => { let date = new Date(0) let user: string function timeAddZeroPrefix(t: number) { if (t >= 0 && t < 10) return '0' + t return t + '' } return messagesList.map((msg) => { const lastDate = date const lastUser = user date = new Date(msg.time) user = msg.user_id const shouldShowTime = msg.user_id != null && (date.getMinutes() != lastDate.getMinutes() || date.getDate() != lastDate.getDate() || date.getMonth() != lastDate.getMonth() || date.getFullYear() != lastDate.getFullYear()) const msgElement = msg.user_id == null ?
: return ( <> { shouldShowTime &&
{ (date.getFullYear() != lastDate.getFullYear() ? `${date.getFullYear()}年` : '') + `${date.getMonth() + 1}月` + `${date.getDate()}日` + ` ${timeAddZeroPrefix(date.getHours())}:${timeAddZeroPrefix(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 && re.ok) 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}>
{ chatInfo.type == 'group' && } { chatInfo.type == 'group' && {chatInfo.is_admin && } }
{ chatInfo.type == 'group' && { uploadChatAvatarRef.current!.click() }} /> {/* { groupPreferenceStore.state.new_member_join_method == 'answered_and_allowed_by_admin' && } */} } { chatInfo.type == 'private' && (
未制作
) }
) }