Compare commits

...

21 Commits

Author SHA1 Message Date
CrescentLeaf
4bcc6e4347 feat: 手動選擇文件 2025-09-25 00:42:31 +08:00
CrescentLeaf
9395104c20 ui: 在加載歷史消息時,自動回到加載前的消息位置
* 使用奇技淫巧
2025-09-25 00:31:40 +08:00
CrescentLeaf
f063c4d165 ui: 修復錯誤添加在 最近對話 搜索框 的 paddingRight 2025-09-24 23:43:24 +08:00
CrescentLeaf
b3b077fa9d ui: 移動端支持修改個人資料, 修繕移動端 UI 的諸多潛在問題 2025-09-24 23:41:11 +08:00
CrescentLeaf
88123e1edb ui: 修正 最近對話 列表的 paddingLeft 2025-09-24 23:39:53 +08:00
CrescentLeaf
0106311a2a chore: make lint happy 2025-09-24 23:09:55 +08:00
CrescentLeaf
f5f2d5743f fix: typo 2025-09-24 23:09:20 +08:00
CrescentLeaf
4e38ad8e20 fix: 移動端未打開對話但提示對話不存在 2025-09-24 23:01:07 +08:00
CrescentLeaf
41362a591c feat: 粘貼文件, 多個同名文件共存發送 2025-09-24 22:57:12 +08:00
CrescentLeaf
1b36a45252 ui: 修繕圖片縮放對話框: 圖片原始位置
* 我盡力了, 這玩意設置位置太靈車了
2025-09-24 22:32:15 +08:00
CrescentLeaf
38db2e1310 fix: 多個同 DeviceId 不同 Session 的客戶端無法同時收到消息 2025-09-24 22:03:23 +08:00
CrescentLeaf
9a3e87d89c chore: 客戶端發送非驗證性請求前, 必須先等待驗證 2025-09-24 21:52:53 +08:00
CrescentLeaf
954b5d3430 ui: 細節優化: 發送消息時, 轉圈 2025-09-24 21:44:52 +08:00
CrescentLeaf
6dfe59c5a8 chore(ui): 圖片加載失敗使用 snackbar 提示 2025-09-24 21:34:04 +08:00
CrescentLeaf
b741cbf9ba chore: 進一步解除傳輸最大限制 2025-09-24 21:33:30 +08:00
CrescentLeaf
d5fbc490ea feat: 支持發送文件
* 目前還只能拖拽到輸入框
2025-09-24 21:33:16 +08:00
CrescentLeaf
276ce5cae8 fix: 控制臺不解析 buffer 2025-09-24 21:32:09 +08:00
CrescentLeaf
3a9312654e chore: 控制臺不解析 buffer
* 額外作用: 加快傳輸效率
2025-09-24 21:19:42 +08:00
CrescentLeaf
0a10009613 chore: localify pinch-zoom 2025-09-24 18:59:25 +08:00
CrescentLeaf
8759b660f5 refactor: randomUUID with fallback 2025-09-24 18:15:41 +08:00
CrescentLeaf
ae3b9c8226 ui: 修正 Tab 指示標顯示不正常 2025-09-24 18:15:04 +08:00
15 changed files with 303 additions and 106 deletions

View File

@@ -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"

View File

@@ -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)
})

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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
View 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)
})
}

View File

@@ -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>
)
}

View File

@@ -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,18 +120,74 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
const [showLoadingMoreMessagesTip, setShowLoadingMoreMessagesTip] = React.useState(false)
const [showNoMoreMessagesTip, setShowNoMoreMessagesTip] = React.useState(false)
async function sendMessage() {
const text = inputRef.current!.value
const [isMessageSending, setIsMessageSending] = React.useState(false)
const re = await Client.invoke("Chat.sendMessage", {
token: data.access_token,
target,
text,
}, 5000)
if (checkApiSuccessOrSncakbar(re, "發送失敗")) return
inputRef.current!.value = ''
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
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
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(`![圖片](${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 (
<div style={{
width: '100%',
@@ -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,15 +259,16 @@ 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={{
<div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markedInstance.parse(msg.text) as string, {
ALLOWED_TAGS: [
"chat-image",
"span",
"chat-link",
]
})
})
}}></div>
</Element_Message>
)
@@ -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>
)

View File

@@ -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)

View File

@@ -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) =>

View File

@@ -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,

View File

@@ -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()
},
}
})

View File

@@ -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: "成功",

View File

@@ -39,7 +39,7 @@ const httpServer: HttpServerLike = (
http.createServer(app)
)
const io = new SocketIo.Server(httpServer, {
maxHttpBufferSize: 1e9,
maxHttpBufferSize: 1e114514,
})
ApiManager.initServer(httpServer, io)

View File

@@ -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>