Compare commits
21 Commits
faec599822
...
4bcc6e4347
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcc6e4347 | ||
|
|
9395104c20 | ||
|
|
f063c4d165 | ||
|
|
b3b077fa9d | ||
|
|
88123e1edb | ||
|
|
0106311a2a | ||
|
|
f5f2d5743f | ||
|
|
4e38ad8e20 | ||
|
|
41362a591c | ||
|
|
1b36a45252 | ||
|
|
38db2e1310 | ||
|
|
9a3e87d89c | ||
|
|
954b5d3430 | ||
|
|
6dfe59c5a8 | ||
|
|
b741cbf9ba | ||
|
|
d5fbc490ea | ||
|
|
276ce5cae8 | ||
|
|
3a9312654e | ||
|
|
0a10009613 | ||
|
|
8759b660f5 | ||
|
|
ae3b9c8226 |
@@ -15,7 +15,15 @@ export type CallMethod =
|
||||
|
||||
"Chat.getInfo" |
|
||||
"Chat.sendMessage" |
|
||||
"Chat.getMessageHistory"
|
||||
"Chat.getMessageHistory" |
|
||||
|
||||
"Chat.uploadFile"
|
||||
|
||||
export const CallableMethodBeforeAuth = [
|
||||
"User.auth",
|
||||
"User.register",
|
||||
"User.login",
|
||||
]
|
||||
|
||||
export type ClientEvent =
|
||||
"Client.onMessage"
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { CallMethod, ClientEvent } from './ApiDeclare.ts'
|
||||
import { CallMethod, ClientEvent, CallableMethodBeforeAuth } from './ApiDeclare.ts'
|
||||
import ApiCallbackMessage from './ApiCallbackMessage.ts'
|
||||
import User from "./client_data/User.ts"
|
||||
import data from "../Data.ts"
|
||||
import { checkApiSuccessOrSncakbar } from "../ui/snackbar.ts"
|
||||
import randomUUID from "../randomUUID.ts"
|
||||
|
||||
type UnknownObject = { [key: string]: unknown }
|
||||
|
||||
class Client {
|
||||
static sessionId = randomUUID()
|
||||
static myUserProfile?: User
|
||||
static socket?: Socket
|
||||
static events: { [key: string]: (data: UnknownObject) => UnknownObject | void } = {}
|
||||
static connected = false
|
||||
static connect() {
|
||||
if (data.device_id == null)
|
||||
data.device_id = crypto.randomUUID()
|
||||
data.device_id = randomUUID()
|
||||
this.socket?.disconnect()
|
||||
this.socket && delete this.socket
|
||||
this.socket = io({
|
||||
transports: ['websocket'],
|
||||
auth: {
|
||||
device_id: data.device_id
|
||||
device_id: data.device_id,
|
||||
session_id: this.sessionId,
|
||||
},
|
||||
})
|
||||
this.socket!.on("connect", async () => {
|
||||
@@ -28,6 +32,10 @@ class Client {
|
||||
}, 1000)
|
||||
if (re.code != 200)
|
||||
checkApiSuccessOrSncakbar(re, "重連失敗")
|
||||
this.connected = true
|
||||
})
|
||||
this.socket!.on("disconnect", () => {
|
||||
this.connected = false
|
||||
})
|
||||
this.socket!.on("The_White_Silk", (name: string, data: UnknownObject, callback: (ret: UnknownObject) => void) => {
|
||||
try {
|
||||
@@ -40,8 +48,7 @@ class Client {
|
||||
})
|
||||
}
|
||||
static invoke(method: CallMethod, args: UnknownObject = {}, timeout: number = 5000): Promise<ApiCallbackMessage> {
|
||||
if (this.socket == null) {
|
||||
console.warn("客戶端未初始化, 等待初始化后再請求......")
|
||||
if (this.socket == null || (!this.connected && !CallableMethodBeforeAuth.includes(method))) {
|
||||
return new Promise((reslove) => {
|
||||
setTimeout(async () => reslove(await this.invoke(method, args, timeout)), 500)
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"crypto-js": "npm:crypto-js@4.2.0",
|
||||
"socket.io-client": "npm:socket.io-client@4.8.1",
|
||||
"marked": "npm:marked@16.3.0",
|
||||
"dompurify": "npm:dompurify@3.2.7"
|
||||
"dompurify": "npm:dompurify@3.2.7",
|
||||
"pinch-zoom-element": "npm:pinch-zoom-element@1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
<title>TheWhiteSilk</title>
|
||||
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.slim.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/GoogleChromeLabs/pinch-zoom@1.1.1/dist/pinch-zoom-min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { breakpoint, Dialog } from "mdui"
|
||||
import * as React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import './ui/custom-elements/chat-image.js'
|
||||
import './ui/custom-elements/chat-image.ts'
|
||||
|
||||
const urlParams = new URL(location.href).searchParams
|
||||
|
||||
|
||||
34
client/randomUUID.ts
Normal file
34
client/randomUUID.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// https://www.xiabingbao.com/post/crypto/js-crypto-randomuuid-qxcuqj.html
|
||||
// 在此表示感謝
|
||||
|
||||
export default function randomUUID() {
|
||||
// crypto - 只支持在安全的上下文使用
|
||||
if (typeof crypto === 'object') {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
||||
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||
const callback = (c: string) => {
|
||||
const num = Number(c)
|
||||
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16)
|
||||
};
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback)
|
||||
}
|
||||
}
|
||||
// 隨機數 - fallback
|
||||
let timestamp = new Date().getTime()
|
||||
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
let random = Math.random() * 16
|
||||
if (timestamp > 0) {
|
||||
random = (timestamp + random) % 16 | 0
|
||||
timestamp = Math.floor(timestamp / 16)
|
||||
} else {
|
||||
random = (perforNow + random) % 16 | 0
|
||||
perforNow = Math.floor(perforNow / 16)
|
||||
}
|
||||
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import Avatar from "./Avatar.tsx"
|
||||
|
||||
import * as React from 'react'
|
||||
import { Dialog, NavigationBar, TextField } from "mdui"
|
||||
import Split from 'split.js'
|
||||
import 'mdui/jsx.zh-cn.d.ts'
|
||||
import { checkApiSuccessOrSncakbar } from "./snackbar.ts"
|
||||
|
||||
@@ -18,8 +17,8 @@ import UserProfileDialog from "./dialog/UserProfileDialog.tsx"
|
||||
import ContactsList from "./main/ContactsList.tsx"
|
||||
import RecentsList from "./main/RecentsList.tsx"
|
||||
import useAsyncEffect from "./useAsyncEffect.ts"
|
||||
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx";
|
||||
import Chat from "../api/client_data/Chat.ts";
|
||||
import ChatInfoDialog from "./dialog/ChatInfoDialog.tsx"
|
||||
import Chat from "../api/client_data/Chat.ts"
|
||||
|
||||
declare global {
|
||||
namespace React {
|
||||
@@ -52,6 +51,10 @@ export default function AppMobile() {
|
||||
const registerInputPasswordRef = React.useRef<TextField>(null)
|
||||
|
||||
const userProfileDialogRef = React.useRef<Dialog>(null)
|
||||
const openMyUserProfileDialogButtonRef = React.useRef<HTMLElement>(null)
|
||||
useEventListener(openMyUserProfileDialogButtonRef, 'click', (_event) => {
|
||||
userProfileDialogRef.current!.open = true
|
||||
})
|
||||
|
||||
const chatInfoDialogRef = React.useRef<Dialog>(null)
|
||||
const [chatInfo, setChatInfo] = React.useState(null as unknown as Chat)
|
||||
@@ -64,13 +67,13 @@ export default function AppMobile() {
|
||||
|
||||
const chatFragmentDialogRef = React.useRef<Dialog>(null)
|
||||
React.useEffect(() => {
|
||||
const shadow = chatFragmentDialogRef.current!.shadowRoot
|
||||
const panel = shadow.querySelector(".panel")
|
||||
const shadow = chatFragmentDialogRef.current!.shadowRoot as ShadowRoot
|
||||
const panel = shadow.querySelector(".panel") as HTMLElement
|
||||
panel.style.padding = '0'
|
||||
panel.style.color = 'inherit'
|
||||
panel.style.backgroundColor = 'rgb(var(--mdui-color-background))'
|
||||
panel.style.setProperty('--mdui-color-background', 'inherit')
|
||||
const body = shadow.querySelector(".body")
|
||||
const body = shadow.querySelector(".body") as HTMLElement
|
||||
body.style.height = '100%'
|
||||
body.style.display = 'flex'
|
||||
})
|
||||
@@ -91,6 +94,7 @@ export default function AppMobile() {
|
||||
<div style={{
|
||||
display: "flex",
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
width: 'var(--whitesilk-window-width)',
|
||||
height: 'var(--whitesilk-window-height)',
|
||||
}}>
|
||||
@@ -100,7 +104,7 @@ export default function AppMobile() {
|
||||
}
|
||||
<div id="ChatFragment" style={{
|
||||
width: '100%',
|
||||
heght: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
<ChatFragment
|
||||
showReturnButton={true}
|
||||
@@ -135,16 +139,33 @@ export default function AppMobile() {
|
||||
}}
|
||||
chat={chatInfo} />
|
||||
|
||||
<mdui-navigation-bar scroll-target="#SideBar" label-visibility="selected" value="Recents" ref={navigationBarRef}>
|
||||
<mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents">最近</mdui-navigation-bar-item>
|
||||
<mdui-navigation-bar-item icon="contacts--outlined" active-icon="contacts--filled" value="Contacts">聯絡人</mdui-navigation-bar-item>
|
||||
</mdui-navigation-bar>
|
||||
<mdui-top-app-bar style={{
|
||||
position: 'sticky',
|
||||
marginTop: '3px',
|
||||
marginRight: '6px',
|
||||
marginLeft: '15px',
|
||||
top: '0px',
|
||||
}}>
|
||||
<mdui-top-app-bar-title>{
|
||||
({
|
||||
Recents: "最近對話",
|
||||
Contacts: "聯絡人"
|
||||
})[navigationItemSelected]
|
||||
}</mdui-top-app-bar-title>
|
||||
<div style={{
|
||||
flexGrow: 1,
|
||||
}}></div>
|
||||
<mdui-button-icon icon="settings"></mdui-button-icon>
|
||||
<mdui-button-icon>
|
||||
<Avatar src={myUserProfileCache?.avatar} text={myUserProfileCache?.nickname} avatarRef={openMyUserProfileDialogButtonRef} />
|
||||
</mdui-button-icon>
|
||||
</mdui-top-app-bar>
|
||||
{
|
||||
// 侧边列表
|
||||
}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
height: 'calc(100% - 80px)',
|
||||
height: 'calc(100% - 80px - 67px)',
|
||||
width: '100%',
|
||||
}} id="SideBar">
|
||||
{
|
||||
@@ -167,6 +188,13 @@ export default function AppMobile() {
|
||||
display={navigationItemSelected == "Contacts"} />
|
||||
}
|
||||
</div>
|
||||
<mdui-navigation-bar label-visibility="selected" value="Recents" ref={navigationBarRef} style={{
|
||||
position: 'sticky',
|
||||
bottom: '0',
|
||||
}}>
|
||||
<mdui-navigation-bar-item icon="watch_later--outlined" active-icon="watch_later--filled" value="Recents">最近</mdui-navigation-bar-item>
|
||||
<mdui-navigation-bar-item icon="contacts--outlined" active-icon="contacts--filled" value="Contacts">聯絡人</mdui-navigation-bar-item>
|
||||
</mdui-navigation-bar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -8,10 +9,11 @@ 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 } from "../snackbar.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<HTMLElement> {
|
||||
target: string
|
||||
@@ -25,12 +27,14 @@ const markedInstance = new marked.Marked({
|
||||
const text = this.parser.parseInline(tokens)
|
||||
return `<span>${text}</span>`
|
||||
},
|
||||
paragraph({ tokens, depth: _depth }) {
|
||||
paragraph({ tokens }) {
|
||||
const text = this.parser.parseInline(tokens)
|
||||
return `<span>${text}</span>`
|
||||
},
|
||||
image({ title, href }) {
|
||||
return `<chat-image src="${href}"></chat-image>`
|
||||
image({ text, href }) {
|
||||
if (/uploaded_files\/[A-Za-z0-9]+$/.test(href))
|
||||
return `<chat-image src="${href}" alt="${text}"></chat-image>`
|
||||
return ``
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -41,11 +45,11 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
title: '加載中...'
|
||||
} as Chat)
|
||||
|
||||
const [tabItemSelected, setTabItemSelected] = React.useState('Chat')
|
||||
const [tabItemSelected, setTabItemSelected] = React.useState('None')
|
||||
const tabRef = React.useRef<Tab>(null)
|
||||
const chatPanelRef = React.useRef<HTMLElement>(null)
|
||||
useEventListener(tabRef, 'change', () => {
|
||||
setTabItemSelected(tabRef.current?.value || "Chat")
|
||||
tabRef.current != null && setTabItemSelected(tabRef.current!.value as string)
|
||||
})
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
@@ -54,10 +58,18 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
target: target,
|
||||
})
|
||||
if (re.code != 200)
|
||||
return checkApiSuccessOrSncakbar(re, "對話錯誤")
|
||||
return target != '' && checkApiSuccessOrSncakbar(re, "對話錯誤")
|
||||
setChatInfo(re.data as Chat)
|
||||
|
||||
loadMore()
|
||||
await loadMore()
|
||||
|
||||
setTabItemSelected("Chat")
|
||||
setTimeout(() => {
|
||||
chatPanelRef.current!.scrollTo({
|
||||
top: 10000000000,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, 100)
|
||||
}, [target])
|
||||
|
||||
const page = React.useRef(0)
|
||||
@@ -75,13 +87,10 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
setTimeout(() => setShowNoMoreMessagesTip(false), 1000)
|
||||
return
|
||||
}
|
||||
setMessagesList(returnMsgs.concat(messagesList))
|
||||
|
||||
if (page.current == 0)
|
||||
setTimeout(() => chatPanelRef.current!.scrollTo({
|
||||
top: 10000000000,
|
||||
behavior: "smooth",
|
||||
}), 100)
|
||||
const oldest = messagesList[0]
|
||||
setMessagesList(returnMsgs.concat(messagesList))
|
||||
setTimeout(() => chatPanelRef.current!.scrollTo({ top: $(`#chat_${target}_message_${oldest.id}`).get(0).offsetTop, behavior: 'smooth' }), 100)
|
||||
|
||||
page.current++
|
||||
}
|
||||
@@ -95,7 +104,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
const { chat, msg } = (data as OnMessageData)
|
||||
if (target == chat) {
|
||||
setMessagesList(messagesList.concat([msg]))
|
||||
if ((chatPanelRef.current!.scrollHeight - chatPanelRef.current!.scrollTop - chatPanelRef.current!.clientHeight) < 80)
|
||||
if ((chatPanelRef.current!.scrollHeight - chatPanelRef.current!.scrollTop - chatPanelRef.current!.clientHeight) < 130)
|
||||
setTimeout(() => chatPanelRef.current!.scrollTo({
|
||||
top: 10000000000,
|
||||
behavior: "smooth",
|
||||
@@ -111,8 +120,27 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
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() {
|
||||
const text = inputRef.current!.value
|
||||
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
|
||||
text = text.replaceAll('(' + fileName + ')', '(' + re.data!.file_path as string + ')')
|
||||
}
|
||||
}
|
||||
|
||||
const re = await Client.invoke("Chat.sendMessage", {
|
||||
token: data.access_token,
|
||||
@@ -121,7 +149,44 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
}, 5000)
|
||||
if (checkApiSuccessOrSncakbar(re, "發送失敗")) return
|
||||
inputRef.current!.value = ''
|
||||
cachedFiles.current = {}
|
||||
} catch (e) {
|
||||
snackbar({
|
||||
message: '發送失敗: ' + (e as Error).message,
|
||||
placement: 'top',
|
||||
})
|
||||
}
|
||||
setIsMessageSending(false)
|
||||
}
|
||||
|
||||
const attachFileInputRef = React.useRef<HTMLInputElement>(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(``)
|
||||
else
|
||||
insertText(``)
|
||||
}
|
||||
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 (
|
||||
<div style={{
|
||||
@@ -131,7 +196,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
flexDirection: 'column',
|
||||
overflowY: 'auto',
|
||||
}} {...props}>
|
||||
<mdui-tabs ref={tabRef} value="Chat" style={{
|
||||
<mdui-tabs ref={tabRef} value={tabItemSelected} style={{
|
||||
position: 'sticky',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -141,13 +206,14 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
showReturnButton && <mdui-button-icon icon="arrow_back" onClick={onReturnButtonClicked} style={{
|
||||
alignSelf: 'center',
|
||||
marginLeft: '5px',
|
||||
marginRight: '15px',
|
||||
marginRight: '5px',
|
||||
}}></mdui-button-icon>
|
||||
}
|
||||
<mdui-tab value="Chat">{
|
||||
chatInfo.title
|
||||
}</mdui-tab>
|
||||
<mdui-tab value="Settings">設定</mdui-tab>
|
||||
<mdui-tab value="None" style={{ display: 'none' }}></mdui-tab>
|
||||
|
||||
<mdui-tab-panel slot="panel" value="Chat" ref={chatPanelRef} style={{
|
||||
display: tabItemSelected == "Chat" ? "flex" : "none",
|
||||
@@ -193,6 +259,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
messagesList.map((msg) =>
|
||||
<Element_Message
|
||||
key={msg.id}
|
||||
id={`chat_${target}_message_${msg.id}`}
|
||||
userId={msg.user_id}>
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
|
||||
@@ -221,31 +288,72 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
paddingRight: '4px',
|
||||
backgroundColor: 'rgb(var(--mdui-color-surface))',
|
||||
}} onDrop={(e) => {
|
||||
if (e.dataTransfer.files) {
|
||||
const files = e.dataTransfer.files
|
||||
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 file of files) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize ref={inputRef as any} max-rows={6} onKeyDown={(event) => {
|
||||
<mdui-text-field variant="outlined" placeholder="喵呜~" autosize ref={inputRef as any} max-rows={6} onChange={() => {
|
||||
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',
|
||||
}}></mdui-text-field>
|
||||
<mdui-button-icon slot="end-icon" icon="more_vert" style={{
|
||||
<mdui-button-icon slot="end-icon" icon="attach_file" style={{
|
||||
marginRight: '6px',
|
||||
}} onClick={() => {
|
||||
attachFileInputRef.current!.click()
|
||||
}}></mdui-button-icon>
|
||||
<mdui-button-icon icon="send" style={{
|
||||
marginRight: '7px',
|
||||
}} onClick={() => sendMessage()}></mdui-button-icon>
|
||||
}} onClick={() => sendMessage()} loading={isMessageSending}></mdui-button-icon>
|
||||
<div style={{
|
||||
display: 'none'
|
||||
}}>
|
||||
<input accept="*/*" type="file" name="選擇附加文檔" multiple ref={attachFileInputRef}></input>
|
||||
</div>
|
||||
</div>
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="Settings" style={{
|
||||
@@ -255,6 +363,8 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
|
||||
}}>
|
||||
Work in progress...
|
||||
</mdui-tab-panel>
|
||||
<mdui-tab-panel slot="panel" value="None">
|
||||
</mdui-tab-panel>
|
||||
</mdui-tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
function openImageViewer(src) {
|
||||
import { $ } from 'mdui/jq'
|
||||
|
||||
import 'pinch-zoom-element'
|
||||
import { snackbar } from "../snackbar.ts";
|
||||
|
||||
function openImageViewer(src: string) {
|
||||
$('#image-viewer-dialog-inner').empty()
|
||||
|
||||
const e = new Image()
|
||||
e.onload = () => ($('#image-viewer-dialog-inner').get(0) as any).scaleTo(0.1, {
|
||||
// Transform origin. Can be a number, or string percent, eg "50%"
|
||||
originX: '50%',
|
||||
originY: '50%',
|
||||
// Should the transform origin be relative to the container, or content?
|
||||
relativeTo: 'container',
|
||||
})
|
||||
e.src = src
|
||||
$('#image-viewer-dialog-inner').append(e)
|
||||
|
||||
e.onload = () => $('#image-viewer-dialog-inner').get(0).setTransform({
|
||||
scale: 0.6,
|
||||
x: $(window).width() / 2 - (e.width / 4),
|
||||
y: $(window).height() / 2 - (e.height / 3),
|
||||
})
|
||||
$('#image-viewer-dialog').get(0).open = true
|
||||
$('#image-viewer-dialog').attr('open', 'true')
|
||||
}
|
||||
|
||||
customElements.define('chat-image', class extends HTMLElement {
|
||||
@@ -21,36 +27,24 @@ customElements.define('chat-image', class extends HTMLElement {
|
||||
const e = new Image()
|
||||
e.style.maxWidth = "100%"
|
||||
e.style.maxHeight = "90%"
|
||||
e.style.marginTop = "13px"
|
||||
e.style.marginTop = '5px'
|
||||
e.style.marginBottom = '5px'
|
||||
e.style.borderRadius = "var(--mdui-shape-corner-medium)"
|
||||
e.src = $(this).attr('src')
|
||||
e.alt = $(this).attr('alt')
|
||||
e.alt = $(this).attr('alt') || ""
|
||||
e.onerror = () => {
|
||||
const bak = $(this).html()
|
||||
$(this).html(`<br/><mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`)
|
||||
const src = $(this).attr('src')
|
||||
$(this).html(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`)
|
||||
$(this).attr('alt', '無法加載圖像')
|
||||
$(this).on('click', () => dialog({
|
||||
headline: "圖片無法載入",
|
||||
description: "您是否需要重新加載?",
|
||||
actions: [
|
||||
{
|
||||
text: "重載",
|
||||
onClick: () => {
|
||||
$(this).html(bak)
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "取消",
|
||||
onClick: () => {
|
||||
return false
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
$(this).on('click', () => {
|
||||
snackbar({
|
||||
message: `圖片 (${src}) 無法加載!`,
|
||||
placement: 'top'
|
||||
})
|
||||
})
|
||||
}
|
||||
e.src = $(this).attr('src') as string
|
||||
e.onclick = () => {
|
||||
openImageViewer($(this).attr('src'))
|
||||
openImageViewer($(this).attr('src') as string)
|
||||
}
|
||||
this.appendChild(e)
|
||||
}
|
||||
@@ -81,11 +75,11 @@ document.body.appendChild(new DOMParser().parseFromString(`
|
||||
}
|
||||
</style>
|
||||
<mdui-button-icon icon="open_in_new"
|
||||
onclick="window.open($('#image-viewer-dialog-inner > *').attr('src'), '_blank')">
|
||||
onclick="window.open(document.querySelector('#image-viewer-dialog-inner > *').src, '_blank')">
|
||||
</mdui-button-icon>
|
||||
<mdui-button-icon icon="close" onclick="this.parentNode.open = false">
|
||||
</mdui-button-icon>
|
||||
<pinch-zoom id="image-viewer-dialog-inner" style="width: var(--whitesilk-window-width); height: var(--whitesilk-window-height);">
|
||||
</pinch-zoom>
|
||||
</mdui-dialog>
|
||||
`, 'text/html').body.firstChild)
|
||||
`, 'text/html').body.firstChild as Node)
|
||||
@@ -30,6 +30,7 @@ export default function RecentsList({
|
||||
return <mdui-list style={{
|
||||
overflowY: 'auto',
|
||||
paddingRight: '10px',
|
||||
paddingLeft: '10px',
|
||||
display: display ? undefined : 'none',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
@@ -37,7 +38,6 @@ export default function RecentsList({
|
||||
<mdui-text-field icon="search" type="search" clearable ref={searchRef} variant="outlined" placeholder="搜索..." style={{
|
||||
marginTop: '5px',
|
||||
marginBottom: '13px',
|
||||
paddingLeft: '10px',
|
||||
}}></mdui-text-field>
|
||||
{
|
||||
recentsList.filter((chat) =>
|
||||
|
||||
@@ -8,6 +8,18 @@ import BaseApi from "./BaseApi.ts"
|
||||
import DataWrongError from "./DataWrongError.ts"
|
||||
import chalk from "chalk"
|
||||
|
||||
function stringifyNotIncludeArrayBuffer(value: any) {
|
||||
return JSON.stringify(value, (_k, v) => {
|
||||
if (v?.type == 'Buffer') {
|
||||
return {
|
||||
type: 'Buffer',
|
||||
data: '[...binary data omitted...]'
|
||||
}
|
||||
}
|
||||
return v
|
||||
})
|
||||
}
|
||||
|
||||
export default class ApiManager {
|
||||
static httpServer: HttpServerLike
|
||||
static socketIoServer: SocketIo.Server
|
||||
@@ -36,6 +48,9 @@ export default class ApiManager {
|
||||
static checkUserIsOnline(userId: string) {
|
||||
return this.getUserClientSockets(userId) != null
|
||||
}
|
||||
/**
|
||||
* 獲取用戶所有的客戶端表 (格式遵循 設備ID_當前Session)
|
||||
*/
|
||||
static getUserClientSockets(userId: string) {
|
||||
return this.clients[userId]
|
||||
}
|
||||
@@ -48,10 +63,12 @@ export default class ApiManager {
|
||||
const ip = socket.conn.remoteAddress
|
||||
|
||||
const deviceId = socket.handshake.auth.device_id as string
|
||||
const sessionId = socket.handshake.auth.session_id as string
|
||||
|
||||
const clientInfo = {
|
||||
userId: '',
|
||||
deviceId,
|
||||
sessionId,
|
||||
ip,
|
||||
socket,
|
||||
}
|
||||
@@ -61,14 +78,14 @@ export default class ApiManager {
|
||||
console.log(chalk.yellow('[斷]') + ` ${ip} disconnected`)
|
||||
else {
|
||||
console.log(chalk.green('[斷]') + ` ${ip} disconnected`)
|
||||
delete this.clients[clientInfo.userId][deviceId]
|
||||
delete this.clients[clientInfo.userId][deviceId + '_' + sessionId]
|
||||
}
|
||||
})
|
||||
console.log(chalk.yellow('[連]') + ` ${ip} connected`)
|
||||
|
||||
socket.on("The_White_Silk", async (name: string, args: { [key: string]: unknown }, callback_: (ret: ApiCallbackMessage) => void) => {
|
||||
function callback(ret: ApiCallbackMessage) {
|
||||
console.log(chalk.blue('[發]') + ` ${ip} <- ${ret.code == 200 ? chalk.green(ret.msg) : chalk.red(ret.msg)} [${ret.code}]${ret.data ? (' <extras: ' + JSON.stringify(ret.data) + '>') : ''}`)
|
||||
console.log(chalk.blue('[發]') + ` ${ip} <- ${ret.code == 200 ? chalk.green(ret.msg) : chalk.red(ret.msg)} [${ret.code}]${ret.data ? (' <extras: ' + stringifyNotIncludeArrayBuffer(ret.data) + '>') : ''}`)
|
||||
return callback_(ret)
|
||||
}
|
||||
async function checkIsPromiseAndAwait(value: Promise<unknown> | unknown) {
|
||||
@@ -81,7 +98,7 @@ export default class ApiManager {
|
||||
msg: "Invalid request.",
|
||||
code: 400
|
||||
})
|
||||
console.log(chalk.red('[收]') + ` ${ip} -> ${chalk.yellow(name)} <args: ${JSON.stringify(args)}>`)
|
||||
console.log(chalk.red('[收]') + ` ${ip} -> ${chalk.yellow(name)} <args: ${stringifyNotIncludeArrayBuffer(args)}>`)
|
||||
|
||||
return callback(await checkIsPromiseAndAwait(this.event_listeners[name]?.(args, clientInfo)) || {
|
||||
code: 501,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import Chat from "../data/Chat.ts";
|
||||
import ChatPrivate from "../data/ChatPrivate.ts"
|
||||
import { Buffer } from "node:buffer"
|
||||
import Chat from "../data/Chat.ts"
|
||||
import FileManager from "../data/FileManager.ts"
|
||||
import MessagesManager from "../data/MessagesManager.ts"
|
||||
import User from "../data/User.ts"
|
||||
@@ -182,13 +181,13 @@ export default class ChatApi extends BaseApi {
|
||||
msg: "用戶無權訪問該對話",
|
||||
}
|
||||
|
||||
const file = await FileManager.uploadFile(args.file_name as string, args.data as Buffer<ArrayBufferLike>)
|
||||
const file = await FileManager.uploadFile(args.file_name as string, args.data as Buffer<ArrayBufferLike>, args.target as string)
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
msg: "成功",
|
||||
data: {
|
||||
messages: MessagesManager.getInstanceForChat(chat).getMessagesWithPage(15, args.page as number),
|
||||
file_path: 'uploaded_files/' + file.getHash()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ export default class UserApi extends BaseApi {
|
||||
msg: "參數缺失",
|
||||
code: 400,
|
||||
}
|
||||
const { deviceId, ip, socket } = clientInfo
|
||||
const { deviceId, ip, socket, sessionId } = clientInfo
|
||||
try {
|
||||
const access_token = TokenManager.decode(args.access_token as string)
|
||||
|
||||
@@ -38,9 +38,9 @@ export default class UserApi extends BaseApi {
|
||||
clientInfo.userId = access_token.author
|
||||
console.log(chalk.green('[驗]') + ` ${access_token.author} authed on Client ${deviceId} (ip = ${ip})`)
|
||||
if (ApiManager.clients[clientInfo.userId] == null) ApiManager.clients[clientInfo.userId] = {
|
||||
[deviceId]: socket
|
||||
[deviceId + '_' + sessionId]: socket
|
||||
}
|
||||
else ApiManager.clients[clientInfo.userId][deviceId] = socket
|
||||
else ApiManager.clients[clientInfo.userId][deviceId + '_' + sessionId] = socket
|
||||
|
||||
return {
|
||||
msg: "成功",
|
||||
|
||||
@@ -39,7 +39,7 @@ const httpServer: HttpServerLike = (
|
||||
http.createServer(app)
|
||||
)
|
||||
const io = new SocketIo.Server(httpServer, {
|
||||
maxHttpBufferSize: 1e9,
|
||||
maxHttpBufferSize: 1e114514,
|
||||
})
|
||||
|
||||
ApiManager.initServer(httpServer, io)
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as SocketIo from "socket.io"
|
||||
type EventCallbackFunction = (args: { [key: string]: unknown }, clientInfo: {
|
||||
userId: string
|
||||
deviceId: string
|
||||
sessionId: string
|
||||
ip: string
|
||||
socket: SocketIo.Socket<SocketIo.DefaultEventsMap, SocketIo.DefaultEventsMap, SocketIo.DefaultEventsMap, any>
|
||||
}) => ApiCallbackMessage | Promise<ApiCallbackMessage>
|
||||
|
||||
Reference in New Issue
Block a user