web->client,结构优化
This commit is contained in:
1 parent
4ceba6a609
commit
ffc73a4212
17 files changed
+15
-21
No files matched your search
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 深蓝色圆背景 -->
|
||||
<circle cx="32" cy="32" r="32" fill="#357ae8" fill-opacity="0.95"/>
|
||||
<!-- 稍微下移且居中的白色锁体 -->
|
||||
<rect x="12" y="20" width="40" height="32" rx="10" fill="#fff"/>
|
||||
<!-- 稍微下移的锁头圆弧 -->
|
||||
<path d="M20 28v-8a12 12 0 0 1 24 0v8" fill="none" stroke="#fff" stroke-width="6"/>
|
||||
<!-- 锁孔与背景同色 -->
|
||||
<circle cx="32" cy="40" r="5" fill="#357ae8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head> <meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NodeCrypt</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container" id="login-container">
|
||||
<h1>Enter a Node</h1>
|
||||
<form id="login-form">
|
||||
<div class="input-group"> <label for="userName">Username</label> <input id="userName" type="text" autocomplete="username" required minlength="1" maxlength="15"> </div>
|
||||
<div class="input-group"> <label for="roomName">Node Name</label> <input id="roomName" type="text" required minlength="1" maxlength="15"> </div>
|
||||
<div class="input-group"> <label for="password">Node Password <span class="optional">(optional)</span></label> <input id="password" type="password" autocomplete="current-password" minlength="1" maxlength="15"> </div> <button type="submit" class="login-btn">ENTER</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="container" id="chat-container" style="display:none;">
|
||||
<div class="mobile-sidebar-mask" id="mobile-sidebar-mask"></div>
|
||||
<div class="mobile-rightbar-mask" id="mobile-rightbar-mask"></div>
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-user"> <span class="avatar" id="sidebar-user-avatar"></span> <span class="sidebar-username" id="sidebar-username">My Name</span> <button class="settings-btn" id="settings-btn" title="设置" aria-label="设置"> <svg width="30px" height="30px" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M10.65 3L9.93163 3.53449L9.32754 5.54812L7.47651 4.55141L6.5906 4.68143L4.68141 6.59062L4.55139 7.47652L5.5481 9.32755L3.53449 9.93163L3 10.65V13.35L3.53449 14.0684L5.54811 14.6725L4.55142 16.5235L4.68144 17.4094L6.59063 19.3186L7.47653 19.4486L9.32754 18.4519L9.93163 20.4655L10.65 21H13.35L14.0684 20.4655L14.6725 18.4519L16.5235 19.4486L17.4094 19.3185L19.3186 17.4094L19.4486 16.5235L18.4519 14.6724L20.4655 14.0684L21 13.35V10.65L20.4655 9.93163L18.4519 9.32754L19.4486 7.47654L19.3186 6.59063L17.4094 4.68144L16.5235 4.55142L14.6725 5.54812L14.0684 3.53449L13.35 3H10.65ZM10.4692 6.96284L11.208 4.5H12.792L13.5308 6.96284L13.8753 7.0946C13.9654 7.12908 14.0543 7.16597 14.142 7.2052L14.4789 7.35598L16.7433 6.13668L17.8633 7.25671L16.644 9.52111L16.7948 9.85803C16.834 9.9457 16.8709 10.0346 16.9054 10.1247L17.0372 10.4692L19.5 11.208V12.792L17.0372 13.5308L16.9054 13.8753C16.8709 13.9654 16.834 14.0543 16.7948 14.1419L16.644 14.4789L17.8633 16.7433L16.7433 17.8633L14.4789 16.644L14.142 16.7948C14.0543 16.834 13.9654 16.8709 13.8753 16.9054L13.5308 17.0372L12.792 19.5H11.208L10.4692 17.0372L10.1247 16.9054C10.0346 16.8709 9.94569 16.834 9.85803 16.7948L9.52111 16.644L7.25671 17.8633L6.13668 16.7433L7.35597 14.4789L7.2052 14.142C7.16597 14.0543 7.12908 13.9654 7.0946 13.8753L6.96284 13.5308L4.5 12.792L4.5 11.208L6.96284 10.4692L7.0946 10.1247C7.12907 10.0346 7.16596 9.94571 7.20519 9.85805L7.35596 9.52113L6.13666 7.2567L7.25668 6.13667L9.5211 7.35598L9.85803 7.2052C9.9457 7.16597 10.0346 7.12908 10.1247 7.0946L10.4692 6.96284ZM14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12ZM15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z"
|
||||
fill="#808080"></path>
|
||||
</g>
|
||||
</svg> </button> </div>
|
||||
<div class="join-room"> <svg class="MuiSvgIcon-root icon-22-middle" focusable="false" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 3.01H3c-1.1 0-2 .9-2 2V9h2V4.99h18v14.03H3V15H1v4.01c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98v-14c0-1.11-.9-2-2-2zM11 16l4-4-4-4v3H1v2h10v3z"></path>
|
||||
</svg> Enter a Node </div>
|
||||
<div class="rooms" id="room-list"> </div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="main-header" id="main-header"> </div>
|
||||
<div class="chat-area" id="chat-area"> </div>
|
||||
<div class="chat-input-area">
|
||||
<div class="rows-wrapper chat-input-wrapper chat-input-main-wrapper chat-rows-wrapper">
|
||||
<div class="new-message-wrapper rows-wrapper-row" data-offset="commands"> <button class="chat-emoji-btn" type="button" title="Emoji"> <svg width="30px" height="30px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M19.5 12C19.5 16.1421 16.1421 19.5 12 19.5C7.85786 19.5 4.5 16.1421 4.5 12C4.5 7.85786 7.85786 4.5 12 4.5C16.1421 4.5 19.5 7.85786 19.5 12ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM9.375 10.5C9.99632 10.5 10.5 9.99632 10.5 9.375C10.5 8.75368 9.99632 8.25 9.375 8.25C8.75368 8.25 8.25 8.75368 8.25 9.375C8.25 9.99632 8.75368 10.5 9.375 10.5ZM15.75 9.375C15.75 9.99632 15.2463 10.5 14.625 10.5C14.0037 10.5 13.5 9.99632 13.5 9.375C13.5 8.75368 14.0037 8.25 14.625 8.25C15.2463 8.25 15.75 8.75368 15.75 9.375ZM12 15C10.1783 15 9 13.8451 9 12.75H7.5C7.5 14.9686 9.67954 16.5 12 16.5C14.3205 16.5 16.5 14.9686 16.5 12.75H15C15 13.8451 13.8217 15 12 15Z"
|
||||
fill="#808080"></path>
|
||||
</g>
|
||||
</svg> </button>
|
||||
<div class="input-message-container">
|
||||
<div class="input-message-input is-empty scrollable scrollable-y no-scrollbar input-message-input-init" contenteditable="true" dir="auto" tabindex="-1"></div> <span class="input-field-placeholder i18n is-empty">Message</span>
|
||||
<div contenteditable="true" tabindex="-1" class="input-message-input is-empty scrollable scrollable-y no-scrollbar input-field-input-fake"></div> <button class="chat-attach-btn chat-attach-btn-abs" type="button" title="Attach file"> <svg width="30px" height="30px"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M10.6875 3.75C8.96439 3.75 7.5 5.23565 7.5 7.15385L7.5 15.4615C7.5 18.1444 9.55201 20.25 12 20.25C14.448 20.25 16.5 18.1444 16.5 15.4615V7.15385H18V15.4615C18 18.8963 15.351 21.75 12 21.75C8.649 21.75 6 18.8963 6 15.4615L6 7.15385C6 4.48383 8.06137 2.25 10.6875 2.25C13.3136 2.25 15.375 4.48383 15.375 7.15385V15.4615C15.375 17.3669 13.9013 18.9808 12 18.9808C10.0987 18.9808 8.625 17.3669 8.625 15.4615V7.15385H10.125V15.4615C10.125 16.615 11.0018 17.4808 12 17.4808C12.9982 17.4808 13.875 16.615 13.875 15.4615V7.15385C13.875 5.23565 12.4106 3.75 10.6875 3.75Z"
|
||||
fill="#808080"></path>
|
||||
</g>
|
||||
</svg></button>
|
||||
</div> <input type="file" multiple class="input-hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rightbar" id="rightbar">
|
||||
<div class="rightbar-header">
|
||||
<div class="rightbar-group">
|
||||
<div class="group-info">
|
||||
<div class="title">Members</div>
|
||||
<div class="group-desc" id="rightbar-group-desc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rightbar-tabs tabs" id="member-tabs"> </div>
|
||||
<div class="members" id="member-list"> </div>
|
||||
</div> </div>
|
||||
<script type="module" src="./js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,626 @@
|
||||
// NodeCrypt core cryptographic client for secure chat
|
||||
// NodeCrypt 安全聊天的核心加密客户端
|
||||
|
||||
import {
|
||||
sha256
|
||||
} from 'js-sha256';
|
||||
import {
|
||||
ec as elliptic
|
||||
} from 'elliptic';
|
||||
import {
|
||||
ModeOfOperation
|
||||
} from 'aes-js';
|
||||
import chacha from 'js-chacha20';
|
||||
import {
|
||||
Buffer
|
||||
} from 'buffer';
|
||||
window.Buffer = Buffer;
|
||||
|
||||
// Main NodeCrypt class for secure communication
|
||||
// 用于安全通信的 NodeCrypt 主类
|
||||
class NodeCrypt {
|
||||
// Initialize NodeCrypt instance
|
||||
// 初始化 NodeCrypt 实例
|
||||
constructor(config = {}, callbacks = {}) {
|
||||
this.config = {
|
||||
rsaPublic: config.rsaPublic || '',
|
||||
wsAddress: config.wsAddress || '',
|
||||
reconnectDelay: config.reconnectDelay || 3000,
|
||||
pingInterval: config.pingInterval || 20000,
|
||||
debug: config.debug || false,
|
||||
};
|
||||
this.callbacks = {
|
||||
onServerClosed: callbacks.onServerClosed || null,
|
||||
onServerSecured: callbacks.onServerSecured || null,
|
||||
onClientSecured: callbacks.onClientSecured || null,
|
||||
onClientList: callbacks.onClientList || null,
|
||||
onClientMessage: callbacks.onClientMessage || null,
|
||||
};
|
||||
this.SERVER_KEY_STORAGE = 'nodecrypt_server_key';
|
||||
try {
|
||||
this.clientEc = new elliptic('curve25519')
|
||||
} catch (error) {
|
||||
this.logEvent('constructor', error, 'error')
|
||||
}
|
||||
this.serverKeys = null;
|
||||
this.serverShared = null;
|
||||
this.credentials = null;
|
||||
this.connection = null;
|
||||
this.reconnect = null;
|
||||
this.ping = null;
|
||||
this.channel = {};
|
||||
this.setCredentials = this.setCredentials.bind(this);
|
||||
this.connect = this.connect.bind(this);
|
||||
this.destruct = this.destruct.bind(this);
|
||||
this.onOpen = this.onOpen.bind(this);
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.logEvent = this.logEvent.bind(this);
|
||||
this.isOpen = this.isOpen.bind(this);
|
||||
this.isClosed = this.isClosed.bind(this);
|
||||
this.startReconnect = this.startReconnect.bind(this);
|
||||
this.stopReconnect = this.stopReconnect.bind(this);
|
||||
this.startPing = this.startPing.bind(this);
|
||||
this.stopPing = this.stopPing.bind(this);
|
||||
this.disconnect = this.disconnect.bind(this);
|
||||
this.sendMessage = this.sendMessage.bind(this);
|
||||
this.sendChannelMessage = this.sendChannelMessage.bind(this);
|
||||
this.encryptServerMessage = this.encryptServerMessage.bind(this);
|
||||
this.decryptServerMessage = this.decryptServerMessage.bind(this);
|
||||
this.encryptClientMessage = this.encryptClientMessage.bind(this);
|
||||
this.decryptClientMessage = this.decryptClientMessage.bind(this)
|
||||
}
|
||||
|
||||
// Set user credentials (username, channel, password)
|
||||
// 设置用户凭证(用户名、频道、密码)
|
||||
setCredentials(username, channel, password) {
|
||||
this.logEvent('setCredentials');
|
||||
try {
|
||||
this.credentials = {
|
||||
username: username,
|
||||
channel: sha256(channel),
|
||||
password: sha256(password)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logEvent('setCredentials', error, 'error');
|
||||
return (false)
|
||||
}
|
||||
return (true)
|
||||
}
|
||||
|
||||
// Connect to the server
|
||||
// 连接到服务器
|
||||
connect() {
|
||||
if (!this.credentials) {
|
||||
return (false)
|
||||
}
|
||||
this.logEvent('connect', this.config.wsAddress);
|
||||
this.stopReconnect();
|
||||
this.stopPing();
|
||||
this.serverKeys = null;
|
||||
this.serverShared = null;
|
||||
this.channel = {};
|
||||
try {
|
||||
this.connection = new WebSocket(this.config.wsAddress);
|
||||
this.connection.onopen = this.onOpen;
|
||||
this.connection.onmessage = this.onMessage;
|
||||
this.connection.onerror = this.onError;
|
||||
this.connection.onclose = this.onClose
|
||||
} catch (error) {
|
||||
this.logEvent('connect', error, 'error');
|
||||
return (false)
|
||||
}
|
||||
return (true)
|
||||
}
|
||||
|
||||
// Clean up and disconnect
|
||||
// 清理并断开连接
|
||||
destruct() {
|
||||
this.logEvent('destruct');
|
||||
this.stopReconnect();
|
||||
this.stopPing();
|
||||
this.reconnect = null;
|
||||
this.ping = null;
|
||||
this.config = {
|
||||
rsaPublic: '',
|
||||
wsAddress: '',
|
||||
reconnectDelay: 3000,
|
||||
pingInterval: 15000,
|
||||
debug: false,
|
||||
};
|
||||
this.callbacks.onServerClosed = null;
|
||||
this.callbacks.onServerSecured = null;
|
||||
this.callbacks.onClientSecured = null;
|
||||
this.callbacks.onClientList = null;
|
||||
this.callbacks.onClientMessage = null;
|
||||
this.clientEc = null;
|
||||
this.serverKeys = null;
|
||||
this.serverShared = null;
|
||||
this.credentials = null;
|
||||
this.connection.onopen = null;
|
||||
this.connection.onmessage = null;
|
||||
this.connection.onerror = null;
|
||||
this.connection.onclose = null;
|
||||
try {
|
||||
this.connection.removeAllListeners()
|
||||
} catch (error) {
|
||||
this.logEvent('destruct', error, 'error')
|
||||
}
|
||||
try {
|
||||
this.connection.close()
|
||||
} catch (error) {
|
||||
this.logEvent('destruct', error, 'error')
|
||||
}
|
||||
this.connection = null;
|
||||
this.channel = {};
|
||||
return (true)
|
||||
}
|
||||
|
||||
// WebSocket open event handler
|
||||
// WebSocket 连接打开事件处理
|
||||
async onOpen() {
|
||||
this.logEvent('onOpen');
|
||||
this.startPing();
|
||||
try {
|
||||
this.serverKeys = await crypto.subtle.generateKey({
|
||||
name: 'ECDH',
|
||||
namedCurve: 'P-384'
|
||||
}, false, ['deriveKey', 'deriveBits']);
|
||||
this.serverShared = null;
|
||||
this.sendMessage(Buffer.from(await crypto.subtle.exportKey('raw', this.serverKeys.publicKey)).toString('hex'))
|
||||
} catch (error) {
|
||||
this.logEvent('onOpen', error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket message event handler
|
||||
// WebSocket 消息事件处理
|
||||
async onMessage(event) {
|
||||
if (!event || !this.isString(event.data)) {
|
||||
return
|
||||
}
|
||||
if (event.data === 'pong') {
|
||||
return
|
||||
}
|
||||
this.logEvent('onMessage', event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'server-key') {
|
||||
const result = await this.handleServerKey(data.key);
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!this.serverShared) {
|
||||
const parts = event.data.split('|');
|
||||
if (!parts[0] || !parts[1]) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (await crypto.subtle.verify({
|
||||
name: 'RSASSA-PKCS1-v1_5'
|
||||
}, await crypto.subtle.importKey('spki', Buffer.from(this.config.rsaPublic, 'base64'), {
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
hash: {
|
||||
name: 'SHA-256'
|
||||
}
|
||||
}, false, ['verify']), Buffer.from(parts[1], 'base64'), Buffer.from(parts[0], 'hex')) === true) {
|
||||
this.serverShared = Buffer.from(await crypto.subtle.deriveBits({
|
||||
name: 'ECDH',
|
||||
namedCurve: 'P-384',
|
||||
public: await crypto.subtle.importKey('raw', Buffer.from(parts[0], 'hex'), {
|
||||
name: 'ECDH',
|
||||
namedCurve: 'P-384'
|
||||
}, true, [])
|
||||
}, this.serverKeys.privateKey, 384)).slice(8, 40);
|
||||
this.sendMessage(this.encryptServerMessage({
|
||||
a: 'j',
|
||||
p: this.credentials.channel
|
||||
}, this.serverShared));
|
||||
if (this.callbacks.onServerSecured) {
|
||||
try {
|
||||
this.callbacks.onServerSecured()
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage-server-secured-callback', error, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage', error, 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
const serverDecrypted = this.decryptServerMessage(event.data, this.serverShared);
|
||||
this.logEvent('onMessage-server-decrypted', serverDecrypted);
|
||||
if (!this.isObject(serverDecrypted) || !this.isString(serverDecrypted.a)) {
|
||||
return
|
||||
}
|
||||
if (serverDecrypted.a === 'l' && this.isArray(serverDecrypted.p)) {
|
||||
try {
|
||||
for (const clientId in this.channel) {
|
||||
if (serverDecrypted.p.indexOf(clientId) < 0) {
|
||||
delete(this.channel[clientId])
|
||||
}
|
||||
}
|
||||
let payloads = {};
|
||||
for (const clientId of serverDecrypted.p) {
|
||||
if (!this.channel[clientId]) {
|
||||
this.channel[clientId] = {
|
||||
username: null,
|
||||
keys: this.clientEc.genKeyPair(),
|
||||
shared: null,
|
||||
};
|
||||
payloads[clientId] = this.channel[clientId].keys.getPublic('hex')
|
||||
}
|
||||
}
|
||||
if (Object.keys(payloads).length > 0) {
|
||||
this.sendMessage(this.encryptServerMessage({
|
||||
a: 'w',
|
||||
p: payloads,
|
||||
}, this.serverShared))
|
||||
}
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage-list', error, 'error')
|
||||
}
|
||||
if (this.callbacks.onClientList) {
|
||||
let clients = [];
|
||||
for (const clientId in this.channel) {
|
||||
if (this.channel[clientId].shared && this.channel[clientId].username) {
|
||||
clients.push({
|
||||
clientId: clientId,
|
||||
username: this.channel[clientId].username
|
||||
})
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.callbacks.onClientList(clients)
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage-client-list-callback', error, 'error')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!this.isString(serverDecrypted.p) || !this.isString(serverDecrypted.c)) {
|
||||
return
|
||||
}
|
||||
if (serverDecrypted.a === 'c' && (!this.channel[serverDecrypted.c] || !this.channel[serverDecrypted.c].shared)) {
|
||||
try {
|
||||
if (!this.channel[serverDecrypted.c]) {
|
||||
this.channel[serverDecrypted.c] = {
|
||||
username: null,
|
||||
keys: this.clientEc.genKeyPair(),
|
||||
shared: null,
|
||||
};
|
||||
this.sendMessage(this.encryptServerMessage({
|
||||
a: 'c',
|
||||
p: this.channel[serverDecrypted.c].keys.getPublic('hex'),
|
||||
c: serverDecrypted.c
|
||||
}, this.serverShared))
|
||||
}
|
||||
this.channel[serverDecrypted.c].shared = Buffer.from(this.xorHex(this.channel[serverDecrypted.c].keys.derive(this.clientEc.keyFromPublic(serverDecrypted.p, 'hex').getPublic()).toString('hex').padEnd(64, '8').substr(0, 64), this.credentials.password), 'hex');
|
||||
this.sendMessage(this.encryptServerMessage({
|
||||
a: 'c',
|
||||
p: this.encryptClientMessage({
|
||||
a: 'u',
|
||||
p: this.credentials.username
|
||||
}, this.channel[serverDecrypted.c].shared),
|
||||
c: serverDecrypted.c
|
||||
}, this.serverShared))
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage-client', error, 'error')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (serverDecrypted.a === 'c' && this.channel[serverDecrypted.c] && this.channel[serverDecrypted.c].shared) {
|
||||
const clientDecrypted = this.decryptClientMessage(serverDecrypted.p, this.channel[serverDecrypted.c].shared);
|
||||
this.logEvent('onMessage-client-decrypted', clientDecrypted);
|
||||
if (!this.isObject(clientDecrypted) || !this.isString(clientDecrypted.a)) {
|
||||
return
|
||||
}
|
||||
if (clientDecrypted.a === 'u' && this.isString(clientDecrypted.p) && clientDecrypted.p.match(/\S+/) && !this.channel[serverDecrypted.c].username) {
|
||||
this.channel[serverDecrypted.c].username = clientDecrypted.p.replace(/^\s+/, '').replace(/\s+$/, '');
|
||||
if (this.callbacks.onClientSecured) {
|
||||
try {
|
||||
this.callbacks.onClientSecured({
|
||||
clientId: serverDecrypted.c,
|
||||
username: this.channel[serverDecrypted.c].username
|
||||
})
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage-client-secured-callback', error, 'error')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!this.channel[serverDecrypted.c].username) {
|
||||
return
|
||||
}
|
||||
if (clientDecrypted.a === 'm' && this.isString(clientDecrypted.t) && this.isString(clientDecrypted.d)) {
|
||||
if (this.callbacks.onClientMessage) {
|
||||
try {
|
||||
this.callbacks.onClientMessage({
|
||||
clientId: serverDecrypted.c,
|
||||
username: this.channel[serverDecrypted.c].username,
|
||||
type: clientDecrypted.t,
|
||||
data: clientDecrypted.d
|
||||
})
|
||||
} catch (error) {
|
||||
this.logEvent('onMessage-client-message-callback', error, 'error')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket error event handler
|
||||
// WebSocket 错误事件处理
|
||||
async onError(event) {
|
||||
this.logEvent('onError', event, 'error');
|
||||
this.disconnect();
|
||||
if (this.credentials) {
|
||||
this.startReconnect()
|
||||
}
|
||||
if (this.callbacks.onServerClosed) {
|
||||
try {
|
||||
this.callbacks.onServerClosed()
|
||||
} catch (error) {
|
||||
this.logEvent('onError-server-closed-callback', error, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket close event handler
|
||||
// WebSocket 关闭事件处理
|
||||
async onClose(event) {
|
||||
this.logEvent('onClose', event);
|
||||
this.disconnect();
|
||||
if (this.credentials) {
|
||||
this.startReconnect()
|
||||
}
|
||||
if (this.callbacks.onServerClosed) {
|
||||
try {
|
||||
this.callbacks.onServerClosed()
|
||||
} catch (error) {
|
||||
this.logEvent('onClose-server-closed-callback', error, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log events for debugging
|
||||
// 记录事件日志用于调试
|
||||
logEvent(source, message, level) {
|
||||
if (this.config.debug) {
|
||||
const date = new Date(),
|
||||
dateString = date.getFullYear() + '-' + ('0' + (date.getMonth() + 1)).slice(-2) + '-' + ('0' + date.getDate()).slice(-2) + ' ' + ('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2) + ':' + ('0' + date.getSeconds()).slice(-2);
|
||||
console.log('[' + dateString + ']', (level ? level.toUpperCase() : 'INFO'), source + (message ? ':' : ''), (message ? message : ''))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if connection is open
|
||||
// 检查连接是否已打开
|
||||
isOpen() {
|
||||
return (this.connection && this.connection.readyState && this.connection.readyState === WebSocket.OPEN ? true : false)
|
||||
}
|
||||
|
||||
// Check if connection is closed
|
||||
// 检查连接是否已关闭
|
||||
isClosed() {
|
||||
return (!this.connection || !this.connection.readyState || this.connection.readyState === WebSocket.CLOSED ? true : false)
|
||||
}
|
||||
|
||||
// Start reconnect timer
|
||||
// 启动重连定时器
|
||||
startReconnect() {
|
||||
this.stopReconnect();
|
||||
this.logEvent('startReconnect');
|
||||
this.reconnect = setTimeout(() => {
|
||||
this.reconnect = null;
|
||||
this.connect()
|
||||
}, this.config.reconnectDelay)
|
||||
}
|
||||
|
||||
// Stop reconnect timer
|
||||
// 停止重连定时器
|
||||
stopReconnect() {
|
||||
if (this.reconnect) {
|
||||
this.logEvent('stopReconnect');
|
||||
clearTimeout(this.reconnect);
|
||||
this.reconnect = null
|
||||
}
|
||||
}
|
||||
|
||||
// Start ping timer
|
||||
// 启动心跳定时器
|
||||
startPing() {
|
||||
this.stopPing();
|
||||
this.logEvent('startPing');
|
||||
this.ping = setInterval(() => {
|
||||
this.sendMessage('ping')
|
||||
}, this.config.pingInterval)
|
||||
}
|
||||
|
||||
// Stop ping timer
|
||||
// 停止心跳定时器
|
||||
stopPing() {
|
||||
if (this.ping) {
|
||||
this.logEvent('stopPing');
|
||||
clearInterval(this.ping);
|
||||
this.ping = null
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from server
|
||||
// 从服务器断开连接
|
||||
disconnect() {
|
||||
this.stopReconnect();
|
||||
this.stopPing();
|
||||
if (!this.isClosed()) {
|
||||
try {
|
||||
this.logEvent('disconnect');
|
||||
this.connection.close()
|
||||
} catch (error) {
|
||||
this.logEvent('disconnect', error, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message to the server
|
||||
// 向服务器发送消息
|
||||
sendMessage(message) {
|
||||
try {
|
||||
if (this.isOpen()) {
|
||||
this.connection.send(message);
|
||||
return (true)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logEvent('sendMessage', error, 'error')
|
||||
}
|
||||
return (false)
|
||||
}
|
||||
|
||||
// Send a message to all channels
|
||||
// 向所有频道发送消息
|
||||
sendChannelMessage(type, data) {
|
||||
if (this.serverShared) {
|
||||
try {
|
||||
let payloads = {};
|
||||
for (const clientId in this.channel) {
|
||||
if (this.channel[clientId].shared && this.channel[clientId].username) {
|
||||
payloads[clientId] = this.encryptClientMessage({
|
||||
a: 'm',
|
||||
t: type,
|
||||
d: data
|
||||
}, this.channel[clientId].shared);
|
||||
if (payloads[clientId].length === 0) {
|
||||
return (false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(payloads).length > 0) {
|
||||
const payload = this.encryptServerMessage({
|
||||
a: 'w',
|
||||
p: payloads,
|
||||
}, this.serverShared);
|
||||
if (!this.isOpen() || payload.length === 0 || payload.length > (8 * 1024 * 1024)) {
|
||||
return (false)
|
||||
}
|
||||
this.connection.send(payload)
|
||||
}
|
||||
return (true)
|
||||
} catch (error) {
|
||||
this.logEvent('sendChannelMessage', error, 'error')
|
||||
}
|
||||
}
|
||||
return (false)
|
||||
}
|
||||
|
||||
// Encrypt a message for the server
|
||||
// 加密发送给服务器的消息
|
||||
encryptServerMessage(message, key) {
|
||||
let encrypted = '';
|
||||
try {
|
||||
message = Buffer.from(JSON.stringify(message), 'utf8');
|
||||
if ((message.length % 16) !== 0) {
|
||||
message = Buffer.from([...message, ...Buffer.alloc(16 - (message.length % 16))])
|
||||
}
|
||||
const iv = Buffer.from(crypto.getRandomValues(new Uint8Array(16)));
|
||||
const cipher = new ModeOfOperation.cbc(key, iv);
|
||||
encrypted = iv.toString('base64') + '|' + Buffer.from(cipher.encrypt(message)).toString('base64')
|
||||
} catch (error) {
|
||||
this.logEvent('encryptServerMessage', error, 'error')
|
||||
}
|
||||
return (encrypted)
|
||||
}
|
||||
|
||||
// Decrypt a message from the server
|
||||
// 解密来自服务器的消息
|
||||
decryptServerMessage(message, key) {
|
||||
let decrypted = {};
|
||||
try {
|
||||
const parts = message.split('|');
|
||||
const decipher = new ModeOfOperation.cbc(key, Buffer.from(parts[0], 'base64'));
|
||||
decrypted = JSON.parse(Buffer.from(decipher.decrypt(Buffer.from(parts[1], 'base64'))).toString('utf8').replace(/\0+$/, ''))
|
||||
} catch (error) {
|
||||
this.logEvent('decryptServerMessage', error, 'error')
|
||||
}
|
||||
return (decrypted)
|
||||
}
|
||||
|
||||
// Encrypt a message for a client
|
||||
// 加密发送给客户端的消息
|
||||
encryptClientMessage(message, key) {
|
||||
let encrypted = '';
|
||||
try {
|
||||
message = Buffer.from(JSON.stringify(message), 'utf8');
|
||||
if ((message.length % 16) !== 0) {
|
||||
message = Buffer.from([...message, ...Buffer.alloc(16 - (message.length % 16))])
|
||||
}
|
||||
const iv = Buffer.from(crypto.getRandomValues(new Uint8Array(12)));
|
||||
const counter = Buffer.from(crypto.getRandomValues(new Uint8Array(4)));
|
||||
const cipher = new chacha(key, iv, counter.reduce((a, b) => a * b));
|
||||
encrypted = iv.toString('base64') + '|' + counter.toString('base64') + '|' + Buffer.from(cipher.encrypt(message)).toString('base64')
|
||||
} catch (error) {
|
||||
this.logEvent('encryptClientMessage', error, 'error')
|
||||
}
|
||||
return (encrypted)
|
||||
}
|
||||
|
||||
// Decrypt a message from a client
|
||||
// 解密来自客户端的消息
|
||||
decryptClientMessage(message, key) {
|
||||
let decrypted = {};
|
||||
try {
|
||||
const parts = message.split('|');
|
||||
const decipher = new chacha(key, Buffer.from(parts[0], 'base64'), Buffer.from(parts[1], 'base64').reduce((a, b) => a * b));
|
||||
decrypted = JSON.parse(Buffer.from(decipher.decrypt(Buffer.from(parts[2], 'base64'))).toString('utf8').replace(/\0+$/, ''))
|
||||
} catch (error) {
|
||||
this.logEvent('decryptClientMessage', error, 'error')
|
||||
}
|
||||
return (decrypted)
|
||||
}
|
||||
|
||||
// XOR two hex strings
|
||||
// 对两个十六进制字符串进行异或
|
||||
xorHex(a, b) {
|
||||
let result = '',
|
||||
hexLength = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < hexLength; ++i) {
|
||||
result += (parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16)).toString(16)
|
||||
}
|
||||
return (result)
|
||||
}
|
||||
|
||||
// Check if value is a string
|
||||
// 检查值是否为字符串
|
||||
isString(value) {
|
||||
return (value && Object.prototype.toString.call(value) === '[object String]' ? true : false)
|
||||
}
|
||||
|
||||
// Check if value is an array
|
||||
// 检查值是否为数组
|
||||
isArray(value) {
|
||||
return (value && Object.prototype.toString.call(value) === '[object Array]' ? true : false)
|
||||
}
|
||||
|
||||
// Check if value is an object
|
||||
// 检查值是否为对象
|
||||
isObject(value) {
|
||||
return (value && Object.prototype.toString.call(value) === '[object Object]' ? true : false)
|
||||
}
|
||||
|
||||
// Handle server public key
|
||||
// 处理服务器公钥
|
||||
async handleServerKey(serverKey) {
|
||||
this.logEvent('handleServerKey', 'Received server key');
|
||||
localStorage.removeItem(this.SERVER_KEY_STORAGE);
|
||||
localStorage.setItem(this.SERVER_KEY_STORAGE, serverKey);
|
||||
this.config.rsaPublic = serverKey;
|
||||
return true
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NodeCrypt = NodeCrypt
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Import necessary modules
|
||||
// 导入必要的模块
|
||||
import {
|
||||
createAvatarSVG
|
||||
} from './util.avatar.js';
|
||||
import {
|
||||
roomsData,
|
||||
activeRoomIndex
|
||||
} from './room.js';
|
||||
import {
|
||||
escapeHTML,
|
||||
textToHTML
|
||||
} from './util.string.js';
|
||||
import {
|
||||
$,
|
||||
$id,
|
||||
createElement,
|
||||
on,
|
||||
addClass,
|
||||
removeClass
|
||||
} from './util.dom.js';
|
||||
|
||||
// Render the chat area
|
||||
// 渲染聊天区域
|
||||
export function renderChatArea() {
|
||||
const chatArea = $id('chat-area');
|
||||
if (!chatArea) return;
|
||||
if (activeRoomIndex < 0 || !roomsData[activeRoomIndex]) {
|
||||
chatArea.innerHTML = '';
|
||||
return
|
||||
}
|
||||
chatArea.innerHTML = '';
|
||||
roomsData[activeRoomIndex].messages.forEach(m => {
|
||||
if (m.type === 'me') addMsg(m.text, true, m.msgType || 'text', m.timestamp);
|
||||
else if (m.type === 'system') addSystemMsg(m.text, true, m.timestamp);
|
||||
else addOtherMsg(m.text, m.userName, m.avatar, true, m.msgType || 'text', m.timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
// Add a message to the chat area
|
||||
// 添加消息到聊天区域
|
||||
export function addMsg(text, isHistory = false, msgType = 'text', timestamp = null) {
|
||||
let ts = isHistory ? timestamp : (timestamp || Date.now());
|
||||
if (!ts) return;
|
||||
if (!isHistory && activeRoomIndex >= 0) {
|
||||
roomsData[activeRoomIndex].messages.push({
|
||||
type: 'me',
|
||||
text,
|
||||
msgType,
|
||||
timestamp: ts
|
||||
})
|
||||
}
|
||||
const chatArea = $id('chat-area');
|
||||
if (!chatArea) return;
|
||||
const className = 'bubble me' + (msgType.includes('_private') ? ' private-message' : '');
|
||||
const date = new Date(ts);
|
||||
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
||||
let contentHtml = '';
|
||||
if (msgType === 'image' || msgType === 'image_private') {
|
||||
const safeImgSrc = escapeHTML(text).replace(/javascript:/gi, '');
|
||||
contentHtml = `<img src="${safeImgSrc}"alt="image"class="bubble-img">`
|
||||
} else {
|
||||
contentHtml = textToHTML(text)
|
||||
}
|
||||
const div = createElement('div', {
|
||||
class: className
|
||||
}, `<span class="bubble-content">${contentHtml}</span><span class="bubble-meta">${time}</span>`);
|
||||
chatArea.appendChild(div);
|
||||
chatArea.scrollTop = chatArea.scrollHeight
|
||||
}
|
||||
|
||||
// Add a message from another user to the chat area
|
||||
// 添加来自其他用户的消息到聊天区域
|
||||
export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false, msgType = 'text', timestamp = null) {
|
||||
if (!userName && activeRoomIndex >= 0) {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
if (rd && msg && msg.clientId && rd.userMap[msg.clientId]) {
|
||||
userName = rd.userMap[msg.clientId].userName || rd.userMap[msg.clientId].username || rd.userMap[msg.clientId].name || 'Anonymous'
|
||||
}
|
||||
}
|
||||
userName = userName || 'Anonymous';
|
||||
let ts = isHistory ? timestamp : (timestamp || Date.now());
|
||||
if (!isHistory && activeRoomIndex >= 0) {
|
||||
roomsData[activeRoomIndex].messages.push({
|
||||
type: 'other',
|
||||
text: msg,
|
||||
userName,
|
||||
avatar,
|
||||
msgType,
|
||||
timestamp: ts
|
||||
})
|
||||
}
|
||||
if (!ts) return;
|
||||
const chatArea = $id('chat-area');
|
||||
if (!chatArea) return;
|
||||
const bubbleWrap = createElement('div', {
|
||||
class: 'bubble-other-wrap'
|
||||
});
|
||||
let contentHtml = '';
|
||||
if (msgType === 'image' || msgType === 'image_private') {
|
||||
const safeImgSrc = escapeHTML(msg).replace(/javascript:/gi, '');
|
||||
contentHtml = `<img src="${safeImgSrc}"alt="image"class="bubble-img">`
|
||||
} else {
|
||||
contentHtml = textToHTML(msg)
|
||||
}
|
||||
const safeUserName = escapeHTML(userName);
|
||||
const date = new Date(ts);
|
||||
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
||||
let bubbleClasses = 'bubble other';
|
||||
if (msgType && msgType.includes('_private')) {
|
||||
bubbleClasses += ' private-message'
|
||||
}
|
||||
bubbleWrap.innerHTML = `<span class="avatar"></span><div class="bubble-other-main"><div class="${bubbleClasses}"><div class="bubble-other-name">${safeUserName}</div><span class="bubble-content">${contentHtml}</span><span class="bubble-meta">${time}</span></div></div>`;
|
||||
const svg = createAvatarSVG(userName);
|
||||
const avatarEl = $('.avatar', bubbleWrap);
|
||||
if (avatarEl) {
|
||||
const cleanSvg = svg.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
avatarEl.innerHTML = cleanSvg
|
||||
}
|
||||
chatArea.appendChild(bubbleWrap);
|
||||
chatArea.scrollTop = chatArea.scrollHeight
|
||||
}
|
||||
|
||||
// Add a system message to the chat area
|
||||
// 添加系统消息到聊天区域
|
||||
export function addSystemMsg(text, isHistory = false, timestamp = null) {
|
||||
if (!isHistory && activeRoomIndex >= 0) {
|
||||
const ts = timestamp || Date.now();
|
||||
roomsData[activeRoomIndex].messages.push({
|
||||
type: 'system',
|
||||
text,
|
||||
timestamp: ts
|
||||
})
|
||||
}
|
||||
const chatArea = $id('chat-area');
|
||||
if (!chatArea) return;
|
||||
const safeText = textToHTML(text);
|
||||
const div = createElement('div', {
|
||||
class: 'bubble system'
|
||||
}, `<span class="bubble-content">${safeText}</span>`);
|
||||
chatArea.appendChild(div);
|
||||
chatArea.scrollTop = chatArea.scrollHeight
|
||||
}
|
||||
|
||||
// Update the style of the chat input area
|
||||
// 更新聊天输入区域的样式
|
||||
export function updateChatInputStyle() {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
const chatInputArea = $('.chat-input-area');
|
||||
const placeholder = $('.input-field-placeholder');
|
||||
const inputMessageInput = $('.input-message-input');
|
||||
if (!chatInputArea || !placeholder || !inputMessageInput) return;
|
||||
if (rd && rd.privateChatTargetId) {
|
||||
addClass(chatInputArea, 'private-mode');
|
||||
addClass(inputMessageInput, 'private-mode');
|
||||
placeholder.textContent = `Private Message to ${escapeHTML(rd.privateChatTargetName)}`
|
||||
} else {
|
||||
removeClass(chatInputArea, 'private-mode');
|
||||
removeClass(inputMessageInput, 'private-mode');
|
||||
placeholder.textContent = 'Message'
|
||||
}
|
||||
const html = inputMessageInput.innerHTML.replace(/<br\s*\/?>(\s*)?/gi, '').replace(/ /g, '').replace(/\u200B/g, '').trim();
|
||||
placeholder.style.opacity = (html === '') ? '1' : '0'
|
||||
}
|
||||
|
||||
// Setup image preview functionality
|
||||
// 设置图片预览功能
|
||||
export function setupImagePreview() {
|
||||
on($id('chat-area'), 'click', function(e) {
|
||||
const target = e.target;
|
||||
if (target.tagName === 'IMG' && target.closest('.bubble-content')) {
|
||||
showImageModal(target.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Show the image modal
|
||||
// 显示图片模态框
|
||||
export function showImageModal(src) {
|
||||
const modal = createElement('div', {
|
||||
class: 'img-modal-bg'
|
||||
}, `<div class="img-modal-blur"></div><div class="img-modal-content img-modal-content-overflow"><img src="${src}"class="img-modal-img"/><span class="img-modal-close">×</span></div>`);
|
||||
document.body.appendChild(modal);
|
||||
on($('.img-modal-close', modal), 'click', () => modal.remove());
|
||||
on(modal, 'click', (e) => {
|
||||
if (e.target === modal) modal.remove()
|
||||
});
|
||||
const img = $('img', modal);
|
||||
let scale = 1;
|
||||
let isDragging = false;
|
||||
let lastX = 0,
|
||||
lastY = 0;
|
||||
let offsetX = 0,
|
||||
offsetY = 0;
|
||||
img.ondragstart = function(e) {
|
||||
e.preventDefault()
|
||||
};
|
||||
on(img, 'wheel', function(ev) {
|
||||
ev.preventDefault();
|
||||
const prevScale = scale;
|
||||
scale += ev.deltaY < 0 ? 0.1 : -0.1;
|
||||
scale = Math.max(0.2, Math.min(5, scale));
|
||||
if (scale === 1) {
|
||||
offsetX = 0;
|
||||
offsetY = 0
|
||||
}
|
||||
updateTransform()
|
||||
});
|
||||
|
||||
function updateTransform() {
|
||||
img.style.transform = `translate(${offsetX}px,${offsetY}px)scale(${scale})`;
|
||||
img.style.cursor = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-in'
|
||||
}
|
||||
on(img, 'mousedown', function(ev) {
|
||||
if (scale <= 1) return;
|
||||
isDragging = true;
|
||||
lastX = ev.clientX;
|
||||
lastY = ev.clientY;
|
||||
img.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none'
|
||||
});
|
||||
|
||||
function onMouseMove(ev) {
|
||||
if (!isDragging) return;
|
||||
offsetX += ev.clientX - lastX;
|
||||
offsetY += ev.clientY - lastY;
|
||||
lastX = ev.clientX;
|
||||
lastY = ev.clientY;
|
||||
updateTransform()
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
img.style.cursor = 'grab';
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}
|
||||
on(window, 'mousemove', onMouseMove);
|
||||
on(window, 'mouseup', onMouseUp);
|
||||
on(img, 'dblclick', function() {
|
||||
scale = 1;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
updateTransform()
|
||||
});
|
||||
const cleanup = () => {
|
||||
off(window, 'mousemove', onMouseMove);
|
||||
off(window, 'mouseup', onMouseUp);
|
||||
document.body.style.userSelect = ''
|
||||
};
|
||||
on(modal, 'remove', cleanup);
|
||||
on($('.img-modal-close', modal), 'click', cleanup);
|
||||
updateTransform()
|
||||
}
|
||||
|
||||
// Automatically adjust the height of the input area
|
||||
// 自动调整输入区域的高度
|
||||
export function autoGrowInput() {
|
||||
const input = $('.input-message-input');
|
||||
if (!input) return;
|
||||
input.style.height = 'auto';
|
||||
input.style.height = input.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
// Handle pasting text as plain text
|
||||
// 处理粘贴为纯文本
|
||||
function handlePasteAsPlainText(element) {
|
||||
if (!element) return;
|
||||
on(element, 'paste', function(e) {
|
||||
e.preventDefault();
|
||||
let text = '';
|
||||
if (e.clipboardData || window.clipboardData) {
|
||||
text = (e.clipboardData || window.clipboardData).getData('text/plain')
|
||||
}
|
||||
if (document.queryCommandSupported('insertText')) {
|
||||
document.execCommand('insertText', false, text)
|
||||
} else {
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const textNode = document.createTextNode(text);
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.setEndAfter(textNode);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Setup input placeholder functionality
|
||||
// 设置输入框占位符功能
|
||||
export function setupInputPlaceholder() {
|
||||
const input = $('.input-message-input');
|
||||
const placeholder = $('.input-field-placeholder');
|
||||
if (!input || !placeholder) return;
|
||||
|
||||
function checkEmpty() {
|
||||
const html = input.innerHTML.replace(/<br\s*\/?>(\s*)?/gi, '').replace(/ /g, '').replace(/\u200B/g, '').trim();
|
||||
if (html === '') {
|
||||
placeholder.style.opacity = '1'
|
||||
} else {
|
||||
placeholder.style.opacity = '0'
|
||||
}
|
||||
autoGrowInput()
|
||||
}
|
||||
on(input, 'input', checkEmpty);
|
||||
on(input, 'blur', checkEmpty);
|
||||
on(input, 'focus', checkEmpty);
|
||||
handlePasteAsPlainText(input);
|
||||
checkEmpty();
|
||||
autoGrowInput();
|
||||
updateChatInputStyle()
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// 导入 NodeCrypt 模块(加密功能模块)
|
||||
// Import the NodeCrypt module (used for encryption)
|
||||
import './NodeCrypt.js';
|
||||
|
||||
// 从 util.image.js 中导入设置图片发送的函数
|
||||
// Import setupImageSend function from util.image.js
|
||||
import {
|
||||
setupImageSend
|
||||
} from './util.image.js';
|
||||
|
||||
// 从 util.emoji.js 中导入设置表情选择器的函数
|
||||
// Import setupEmojiPicker function from util.emoji.js
|
||||
import {
|
||||
setupEmojiPicker
|
||||
} from './util.emoji.js';
|
||||
|
||||
// 从 util.settings.js 中导入设置面板的功能函数
|
||||
// Import functions for settings panel from util.settings.js
|
||||
import {
|
||||
openSettingsPanel, // 打开设置面板 / Open settings panel
|
||||
closeSettingsPanel, // 关闭设置面板 / Close settings panel
|
||||
initSettings, // 初始化设置 / Initialize settings
|
||||
notifyMessage // 通知信息提示 / Display notification message
|
||||
} from './util.settings.js';
|
||||
|
||||
// 从 util.dom.js 中导入常用 DOM 操作函数
|
||||
// Import common DOM manipulation functions from util.dom.js
|
||||
import {
|
||||
$, // 简化的 document.querySelector / Simplified selector
|
||||
$id, // document.getElementById 的简写 / Shortcut for getElementById
|
||||
removeClass // 移除类名 / Remove a CSS class
|
||||
} from './util.dom.js';
|
||||
|
||||
// 从 room.js 中导入房间管理相关变量和函数
|
||||
// Import room-related variables and functions from room.js
|
||||
import {
|
||||
roomsData, // 当前所有房间的数据 / Data of all rooms
|
||||
activeRoomIndex, // 当前激活的房间索引 / Index of the active room
|
||||
joinRoom // 加入房间的函数 / Function to join a room
|
||||
} from './room.js';
|
||||
|
||||
// 从 chat.js 中导入聊天功能相关的函数
|
||||
// Import chat-related functions from chat.js
|
||||
import {
|
||||
addMsg, // 添加普通消息到聊天窗口 / Add a normal message to chat
|
||||
addSystemMsg, // 添加系统消息 / Add a system message
|
||||
setupImagePreview, // 设置图片预览功能 / Setup image preview
|
||||
setupInputPlaceholder // 设置输入框的占位提示 / Setup placeholder for input box
|
||||
} from './chat.js';
|
||||
|
||||
// 从 ui.js 中导入 UI 界面相关的功能
|
||||
// Import user interface functions from ui.js
|
||||
import {
|
||||
renderUserList, // 渲染用户列表 / Render user list
|
||||
renderMainHeader, // 渲染主标题栏 / Render main header
|
||||
setupMoreBtnMenu, // 设置更多按钮的下拉菜单 / Setup "more" button menu
|
||||
preventSpaceInput, // 防止输入空格 / Prevent space input in form fields
|
||||
loginFormHandler, // 登录表单提交处理器 / Login form handler
|
||||
openLoginModal, // 打开登录窗口 / Open login modal
|
||||
setupTabs, // 设置页面标签切换 / Setup tab switching
|
||||
autofillRoomPwd // 自动填充房间密码 / Autofill room password
|
||||
} from './ui.js';
|
||||
|
||||
// 设置全局配置参数
|
||||
// Set global configuration parameters
|
||||
window.config = {
|
||||
wsAddress: 'wss://crypt.works/ws', // WebSocket 服务器地址 / WebSocket server address
|
||||
debug: false // 是否开启调试模式 / Enable debug mode
|
||||
};
|
||||
|
||||
// 把一些函数挂载到 window 对象上供其他模块使用
|
||||
// Expose functions to the global window object for accessibility
|
||||
window.addSystemMsg = addSystemMsg;
|
||||
window.joinRoom = joinRoom;
|
||||
window.notifyMessage = notifyMessage;
|
||||
window.setupEmojiPicker = setupEmojiPicker;
|
||||
|
||||
// 当 DOM 内容加载完成后执行初始化逻辑
|
||||
// Run initialization logic when the DOM content is fully loaded
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const loginContainer = $id('login-container'); // 登录容器 / Login container
|
||||
const chatContainer = $id('chat-container'); // 聊天容器 / Chat container
|
||||
const loginForm = $id('login-form'); // 登录表单 / Login form
|
||||
|
||||
if (loginForm) {
|
||||
// 监听登录表单提交事件 / Listen to login form submission
|
||||
loginForm.addEventListener('submit', loginFormHandler(null))
|
||||
}
|
||||
|
||||
const joinBtn = $('.join-room'); // 加入房间按钮 / Join room button
|
||||
if (joinBtn) {
|
||||
joinBtn.onclick = openLoginModal; // 点击打开登录窗口 / Click to open login modal
|
||||
}
|
||||
|
||||
// 阻止用户输入用户名、房间名和密码时输入空格
|
||||
// Prevent space input for username, room name, and password fields
|
||||
preventSpaceInput($id('userName'));
|
||||
preventSpaceInput($id('roomName'));
|
||||
preventSpaceInput($id('password'));
|
||||
|
||||
// 初始化辅助功能和界面设置
|
||||
// Initialize autofill, input placeholders, and menus
|
||||
autofillRoomPwd();
|
||||
setupInputPlaceholder();
|
||||
setupMoreBtnMenu();
|
||||
setupImagePreview();
|
||||
setupEmojiPicker();
|
||||
initSettings();
|
||||
|
||||
const settingsBtn = $id('settings-btn'); // 设置按钮 / Settings button
|
||||
if (settingsBtn) {
|
||||
settingsBtn.onclick = (e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡 / Stop event from bubbling
|
||||
openSettingsPanel(); // 打开设置面板 / Open settings panel
|
||||
}
|
||||
}
|
||||
|
||||
// 点击其他地方时关闭设置面板
|
||||
// Close settings panel when clicking outside
|
||||
document.addEventListener('click', (ev) => {
|
||||
const panel = $id('settings-panel');
|
||||
if (panel && panel.style.display === 'block') {
|
||||
const card = panel.querySelector('.settings-panel-card');
|
||||
if (card && !card.contains(ev.target) && ev.target.id !== 'settings-btn') {
|
||||
closeSettingsPanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const input = document.querySelector('.input-message-input'); // 消息输入框 / Message input box
|
||||
if (input) {
|
||||
input.focus(); // 自动聚焦 / Auto focus
|
||||
input.addEventListener('keydown', (e) => {
|
||||
// 按下 Enter 键并且不按 Shift,表示发送消息
|
||||
// Pressing Enter (without Shift) sends the message
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = input.innerText.trim(); // 获取输入的文本 / Get input text
|
||||
const rd = roomsData[activeRoomIndex]; // 当前房间数据 / Current room data
|
||||
if (text && rd && rd.chat) {
|
||||
if (rd.privateChatTargetId) {
|
||||
// 私聊消息加密并发送
|
||||
// Encrypt and send private message
|
||||
const targetClient = rd.chat.channel[rd.privateChatTargetId];
|
||||
if (targetClient && targetClient.shared) {
|
||||
const clientMessagePayload = {
|
||||
a: 'm',
|
||||
t: 'text_private',
|
||||
d: text
|
||||
};
|
||||
const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared);
|
||||
const serverRelayPayload = {
|
||||
a: 'c',
|
||||
p: encryptedClientMessage,
|
||||
c: rd.privateChatTargetId
|
||||
};
|
||||
const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared);
|
||||
rd.chat.sendMessage(encryptedMessageForServer);
|
||||
addMsg(text, false, 'text_private');
|
||||
} else {
|
||||
addSystemMsg(`Cannot send private message to ${rd.privateChatTargetName}.User might not be fully connected.`)
|
||||
}
|
||||
} else {
|
||||
// 公共频道消息发送
|
||||
// Send public message
|
||||
rd.chat.sendChannelMessage('text', text);
|
||||
addMsg(text);
|
||||
}
|
||||
// 清空输入框并触发 input 事件
|
||||
// Clear input and trigger input event
|
||||
input.innerText = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 设置发送图片功能
|
||||
// Setup image sending functionality
|
||||
setupImageSend({
|
||||
inputSelector: '.input-message-input', // 消息输入框选择器 / Message input selector
|
||||
attachBtnSelector: '.chat-attach-btn', // 附件按钮选择器 / Attach button selector
|
||||
fileInputSelector: '.new-message-wrapper input[type="file"]', // 文件输入框选择器 / File input selector
|
||||
onSend: (dataUrl) => {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
if (rd && rd.chat) {
|
||||
if (rd.privateChatTargetId) {
|
||||
// 私聊图片加密并发送
|
||||
// Encrypt and send private image
|
||||
const targetClient = rd.chat.channel[rd.privateChatTargetId];
|
||||
if (targetClient && targetClient.shared) {
|
||||
const clientMessagePayload = {
|
||||
a: 'm',
|
||||
t: 'image_private',
|
||||
d: dataUrl
|
||||
};
|
||||
const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared);
|
||||
const serverRelayPayload = {
|
||||
a: 'c',
|
||||
p: encryptedClientMessage,
|
||||
c: rd.privateChatTargetId
|
||||
};
|
||||
const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared);
|
||||
rd.chat.sendMessage(encryptedMessageForServer);
|
||||
addMsg(dataUrl, false, 'image_private');
|
||||
} else {
|
||||
addSystemMsg(`Cannot send private image to ${rd.privateChatTargetName}.User might not be fully connected.`)
|
||||
}
|
||||
} else {
|
||||
// 公共频道发送图片
|
||||
// Send image to public channel
|
||||
rd.chat.sendChannelMessage('image', dataUrl);
|
||||
addMsg(dataUrl, false, 'image');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 判断是否为移动端
|
||||
// Check if the device is mobile
|
||||
const isMobile = () => window.innerWidth <= 768;
|
||||
|
||||
// 渲染主界面元素
|
||||
// Render main UI elements
|
||||
renderMainHeader();
|
||||
renderUserList();
|
||||
setupTabs();
|
||||
|
||||
const roomList = $id('room-list');
|
||||
const sidebar = $id('sidebar');
|
||||
const rightbar = $id('rightbar');
|
||||
const sidebarMask = $id('mobile-sidebar-mask');
|
||||
const rightbarMask = $id('mobile-rightbar-mask');
|
||||
|
||||
// 在移动端点击房间列表后关闭侧边栏
|
||||
// On mobile, clicking room list closes sidebar
|
||||
if (roomList) {
|
||||
roomList.addEventListener('click', () => {
|
||||
if (isMobile()) {
|
||||
sidebar?.classList.remove('mobile-open');
|
||||
sidebarMask?.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 在移动端点击成员标签后关闭右侧面板
|
||||
// On mobile, clicking member tabs closes right panel
|
||||
const memberTabs = $id('member-tabs');
|
||||
if (memberTabs) {
|
||||
memberTabs.addEventListener('click', () => {
|
||||
if (isMobile()) {
|
||||
removeClass(rightbar, 'mobile-open');
|
||||
removeClass(rightbarMask, 'active');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
// Room management logic for NodeCrypt web client
|
||||
// NodeCrypt 网页客户端的房间管理逻辑
|
||||
|
||||
import {
|
||||
createAvatarSVG
|
||||
} from './util.avatar.js';
|
||||
import {
|
||||
renderChatArea,
|
||||
addSystemMsg,
|
||||
updateChatInputStyle
|
||||
} from './chat.js';
|
||||
import {
|
||||
renderMainHeader,
|
||||
renderUserList
|
||||
} from './ui.js';
|
||||
import {
|
||||
escapeHTML
|
||||
} from './util.string.js';
|
||||
import {
|
||||
$id,
|
||||
createElement
|
||||
} from './util.dom.js';
|
||||
let roomsData = [];
|
||||
let activeRoomIndex = -1;
|
||||
|
||||
// Get a new room data object
|
||||
// 获取一个新的房间数据对象
|
||||
export function getNewRoomData() {
|
||||
return {
|
||||
roomName: '',
|
||||
userList: [],
|
||||
userMap: {},
|
||||
myId: null,
|
||||
myUserName: '',
|
||||
chat: null,
|
||||
messages: [],
|
||||
prevUserList: [],
|
||||
knownUserIds: new Set(),
|
||||
unreadCount: 0,
|
||||
privateChatTargetId: null,
|
||||
privateChatTargetName: null
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to another room by index
|
||||
// 切换到指定索引的房间
|
||||
export function switchRoom(index) {
|
||||
if (index < 0 || index >= roomsData.length) return;
|
||||
activeRoomIndex = index;
|
||||
const rd = roomsData[index];
|
||||
if (typeof rd.unreadCount === 'number') rd.unreadCount = 0;
|
||||
const sidebarUsername = document.getElementById('sidebar-username');
|
||||
if (sidebarUsername) sidebarUsername.textContent = rd.myUserName;
|
||||
setSidebarAvatar(rd.myUserName);
|
||||
renderRooms(index);
|
||||
renderMainHeader();
|
||||
renderUserList(false);
|
||||
renderChatArea();
|
||||
updateChatInputStyle()
|
||||
}
|
||||
|
||||
// Set the sidebar avatar
|
||||
// 设置侧边栏头像
|
||||
export function setSidebarAvatar(userName) {
|
||||
if (!userName) return;
|
||||
const svg = createAvatarSVG(userName);
|
||||
const el = $id('sidebar-user-avatar');
|
||||
if (el) {
|
||||
const cleanSvg = svg.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
el.innerHTML = cleanSvg
|
||||
}
|
||||
}
|
||||
|
||||
// Render the room list
|
||||
// 渲染房间列表
|
||||
export function renderRooms(activeId = 0) {
|
||||
const roomList = $id('room-list');
|
||||
roomList.innerHTML = '';
|
||||
roomsData.forEach((rd, i) => {
|
||||
const div = createElement('div', {
|
||||
class: 'room' + (i === activeId ? ' active' : ''),
|
||||
onclick: () => switchRoom(i)
|
||||
});
|
||||
const safeRoomName = escapeHTML(rd.roomName);
|
||||
let unreadHtml = '';
|
||||
if (rd.unreadCount && i !== activeId) {
|
||||
unreadHtml = `<span class="room-unread-badge">${rd.unreadCount>99?'99+':rd.unreadCount}</span>`
|
||||
}
|
||||
div.innerHTML = `<div class="info"><div class="title">#${safeRoomName}</div></div>${unreadHtml}`;
|
||||
roomList.appendChild(div)
|
||||
})
|
||||
}
|
||||
|
||||
// Join a room
|
||||
// 加入一个房间
|
||||
export function joinRoom(userName, roomName, password, modal = null, onResult) {
|
||||
const newRd = getNewRoomData();
|
||||
newRd.roomName = roomName;
|
||||
newRd.myUserName = userName;
|
||||
newRd.password = password;
|
||||
roomsData.push(newRd);
|
||||
const idx = roomsData.length - 1;
|
||||
switchRoom(idx);
|
||||
const sidebarUsername = $id('sidebar-username');
|
||||
if (sidebarUsername) sidebarUsername.textContent = userName;
|
||||
setSidebarAvatar(userName);
|
||||
let closed = false;
|
||||
const callbacks = {
|
||||
onServerClosed: () => {
|
||||
setStatus('Node connection closed');
|
||||
if (onResult && !closed) {
|
||||
closed = true;
|
||||
onResult(false)
|
||||
}
|
||||
},
|
||||
onServerSecured: () => {
|
||||
setStatus('Secure connection to node');
|
||||
if (modal) modal.remove();
|
||||
else {
|
||||
const loginContainer = $id('login-container');
|
||||
if (loginContainer) loginContainer.style.display = 'none';
|
||||
const chatContainer = $id('chat-container');
|
||||
if (chatContainer) chatContainer.style.display = ''
|
||||
}
|
||||
if (onResult && !closed) {
|
||||
closed = true;
|
||||
onResult(true)
|
||||
}
|
||||
addSystemMsg("Server connection secured")
|
||||
},
|
||||
onClientSecured: (user) => handleClientSecured(idx, user),
|
||||
onClientList: (list, selfId) => handleClientList(idx, list, selfId),
|
||||
onClientLeft: (clientId) => handleClientLeft(idx, clientId),
|
||||
onClientMessage: (msg) => handleClientMessage(idx, msg)
|
||||
};
|
||||
const chatInst = new window.NodeCrypt(window.config, callbacks);
|
||||
chatInst.setCredentials(userName, roomName, password);
|
||||
chatInst.connect();
|
||||
roomsData[idx].chat = chatInst
|
||||
}
|
||||
|
||||
// Handle the client list update
|
||||
// 处理客户端列表更新
|
||||
export function handleClientList(idx, list, selfId) {
|
||||
const rd = roomsData[idx];
|
||||
if (!rd) return;
|
||||
const oldUserIds = new Set((rd.userList || []).map(u => u.clientId));
|
||||
const newUserIds = new Set(list.map(u => u.clientId));
|
||||
for (const oldId of oldUserIds) {
|
||||
if (!newUserIds.has(oldId)) {
|
||||
handleClientLeft(idx, oldId)
|
||||
}
|
||||
}
|
||||
rd.userList = list;
|
||||
rd.userMap = {};
|
||||
list.forEach(u => {
|
||||
rd.userMap[u.clientId] = u
|
||||
});
|
||||
rd.myId = selfId;
|
||||
if (activeRoomIndex === idx) {
|
||||
renderUserList(false);
|
||||
renderMainHeader()
|
||||
}
|
||||
rd.initCount = (rd.initCount || 0) + 1;
|
||||
if (rd.initCount === 2) {
|
||||
rd.isInitialized = true;
|
||||
rd.knownUserIds = new Set(list.map(u => u.clientId))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle client secured event
|
||||
// 处理客户端安全连接事件
|
||||
export function handleClientSecured(idx, user) {
|
||||
const rd = roomsData[idx];
|
||||
if (!rd) return;
|
||||
rd.userMap[user.clientId] = user;
|
||||
const existingUserIndex = rd.userList.findIndex(u => u.clientId === user.clientId);
|
||||
if (existingUserIndex === -1) {
|
||||
rd.userList.push(user)
|
||||
} else {
|
||||
rd.userList[existingUserIndex] = user
|
||||
}
|
||||
if (activeRoomIndex === idx) {
|
||||
renderUserList(false);
|
||||
renderMainHeader()
|
||||
}
|
||||
if (!rd.isInitialized) {
|
||||
return
|
||||
}
|
||||
const isNew = !rd.knownUserIds.has(user.clientId);
|
||||
if (isNew) {
|
||||
rd.knownUserIds.add(user.clientId);
|
||||
const name = user.userName || user.username || user.name || 'Anonymous';
|
||||
const msg = `${name}joined`;
|
||||
rd.messages.push({
|
||||
type: 'system',
|
||||
text: msg
|
||||
});
|
||||
if (activeRoomIndex === idx) addSystemMsg(msg, true);
|
||||
if (window.notifyMessage) {
|
||||
window.notifyMessage(rd.roomName, 'system', msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle client left event
|
||||
// 处理客户端离开事件
|
||||
export function handleClientLeft(idx, clientId) {
|
||||
const rd = roomsData[idx];
|
||||
if (!rd) return;
|
||||
if (rd.privateChatTargetId === clientId) {
|
||||
rd.privateChatTargetId = null;
|
||||
rd.privateChatTargetName = null;
|
||||
if (activeRoomIndex === idx) {
|
||||
updateChatInputStyle()
|
||||
}
|
||||
}
|
||||
const user = rd.userMap[clientId];
|
||||
const name = user ? (user.userName || user.username || user.name || 'Anonymous') : 'Anonymous';
|
||||
const msg = `${name}left`;
|
||||
rd.messages.push({
|
||||
type: 'system',
|
||||
text: msg
|
||||
});
|
||||
if (activeRoomIndex === idx) addSystemMsg(msg, true);
|
||||
rd.userList = rd.userList.filter(u => u.clientId !== clientId);
|
||||
delete rd.userMap[clientId];
|
||||
if (activeRoomIndex === idx) {
|
||||
renderUserList(false);
|
||||
renderMainHeader()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle client message event
|
||||
// 处理客户端消息事件
|
||||
export function handleClientMessage(idx, msg) {
|
||||
const newRd = roomsData[idx];
|
||||
if (!newRd) return;
|
||||
if (msg.userName === newRd.myUserName && !msg.type.includes('_private')) {
|
||||
const currentRd = roomsData[idx];
|
||||
if (currentRd && msg.clientId === currentRd.myId && msg.type.includes('_private')) {} else if (msg.clientId === newRd.myId) {
|
||||
return
|
||||
}
|
||||
}
|
||||
let msgType = msg.type || 'text';
|
||||
if (!msgType.includes('_private') && msg.data && msg.data.startsWith('data:image/')) {
|
||||
msgType = 'image'
|
||||
}
|
||||
let realUserName = msg.userName;
|
||||
if (!realUserName && msg.clientId && newRd.userMap[msg.clientId]) {
|
||||
realUserName = newRd.userMap[msg.clientId].userName || newRd.userMap[msg.clientId].username || newRd.userMap[msg.clientId].name
|
||||
}
|
||||
roomsData[idx].messages.push({
|
||||
type: 'other',
|
||||
text: msg.data,
|
||||
userName: realUserName,
|
||||
avatar: realUserName,
|
||||
msgType,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
const notificationMsgType = msgType.includes('_private') ? `private ${msgType.split('_')[0]}` : msgType;
|
||||
if (window.notifyMessage) {
|
||||
window.notifyMessage(newRd.roomName, notificationMsgType, msg.data, realUserName)
|
||||
}
|
||||
if (activeRoomIndex !== idx) {
|
||||
roomsData[idx].unreadCount = (roomsData[idx].unreadCount || 0) + 1;
|
||||
renderRooms(activeRoomIndex)
|
||||
} else {
|
||||
renderChatArea()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle private chat with a user
|
||||
// 切换与某用户的私聊
|
||||
export function togglePrivateChat(targetId, targetName) {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
if (!rd) return;
|
||||
if (rd.privateChatTargetId === targetId) {
|
||||
rd.privateChatTargetId = null;
|
||||
rd.privateChatTargetName = null
|
||||
} else {
|
||||
rd.privateChatTargetId = targetId;
|
||||
rd.privateChatTargetName = targetName
|
||||
}
|
||||
renderUserList();
|
||||
updateChatInputStyle()
|
||||
}
|
||||
|
||||
// Set the status bar text
|
||||
// 设置状态栏文本
|
||||
export function setStatus(text) {
|
||||
let statusBar = $id('status-bar');
|
||||
if (!statusBar) {
|
||||
statusBar = createElement('div', {
|
||||
id: 'status-bar',
|
||||
style: 'color:green;padding:4px 10px;font-size:13px;'
|
||||
});
|
||||
document.body.appendChild(statusBar)
|
||||
}
|
||||
statusBar.innerText = text
|
||||
}
|
||||
|
||||
// Exit the current room
|
||||
// 退出当前房间
|
||||
export function exitRoom() {
|
||||
if (activeRoomIndex >= 0 && roomsData[activeRoomIndex]) {
|
||||
const chatInst = roomsData[activeRoomIndex].chat;
|
||||
if (chatInst && typeof chatInst.destruct === 'function') {
|
||||
chatInst.destruct()
|
||||
} else if (chatInst && typeof chatInst.disconnect === 'function') {
|
||||
chatInst.disconnect()
|
||||
}
|
||||
roomsData[activeRoomIndex].chat = null;
|
||||
roomsData.splice(activeRoomIndex, 1);
|
||||
if (roomsData.length > 0) {
|
||||
switchRoom(0);
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export {
|
||||
roomsData,
|
||||
activeRoomIndex
|
||||
};
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
// UI logic for NodeCrypt web client
|
||||
// NodeCrypt 网页客户端的 UI 逻辑
|
||||
|
||||
import {
|
||||
createAvatarSVG
|
||||
} from './util.avatar.js';
|
||||
import {
|
||||
roomsData,
|
||||
activeRoomIndex,
|
||||
togglePrivateChat,
|
||||
exitRoom
|
||||
} from './room.js';
|
||||
import {
|
||||
escapeHTML
|
||||
} from './util.string.js';
|
||||
import {
|
||||
$id
|
||||
} from './util.dom.js';
|
||||
|
||||
// Render the main header
|
||||
// 渲染主标题栏
|
||||
export function renderMainHeader() {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
let roomName = rd ? rd.roomName : 'Room';
|
||||
let onlineCount = rd && rd.userList ? rd.userList.length : 0;
|
||||
if (rd && !rd.userList.some(u => u.clientId === rd.myId)) {
|
||||
onlineCount += 1
|
||||
}
|
||||
const safeRoomName = escapeHTML(roomName);
|
||||
$id("main-header").innerHTML = `<button class="mobile-menu-btn"id="mobile-menu-btn"aria-label="Open Sidebar"><svg width="35px"height="35px"viewBox="0 0 24 24"fill="none"xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier"stroke-width="0"></g><g id="SVGRepo_tracerCarrier"stroke-linecap="round"stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill-rule="evenodd"clip-rule="evenodd"d="M21.4498 10.275L11.9998 3.1875L2.5498 10.275L2.9998 11.625H3.7498V20.25H20.2498V11.625H20.9998L21.4498 10.275ZM5.2498 18.75V10.125L11.9998 5.0625L18.7498 10.125V18.75H14.9999V14.3333L14.2499 13.5833H9.74988L8.99988 14.3333V18.75H5.2498ZM10.4999 18.75H13.4999V15.0833H10.4999V18.75Z"fill="#808080"></path></g></svg></button><div class="main-header-center"id="main-header-center"><div class="main-header-flex"><div class="group-title group-title-bold">#${safeRoomName}</div><span class="main-header-members">${onlineCount}members</span></div></div><div class="main-header-actions"><button class="more-btn"id="more-btn"aria-label="More Options"><svg width="35px"height="35px"viewBox="0 0 24 24"fill="none"xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier"stroke-width="0"></g><g id="SVGRepo_tracerCarrier"stroke-linecap="round"stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><circle cx="12"cy="6"r="1.5"fill="#808080"></circle><circle cx="12"cy="12"r="1.5"fill="#808080"></circle><circle cx="12"cy="18"r="1.5"fill="#808080"></circle></g></svg></button><button class="mobile-info-btn"id="mobile-info-btn"aria-label="Open Members"><svg width="35px"height="35px"viewBox="0 0 24 24"fill="none"xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier"stroke-width="0"></g><g id="SVGRepo_tracerCarrier"stroke-linecap="round"stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill-rule="evenodd"clip-rule="evenodd"d="M16.0603 18.307C14.89 19.0619 13.4962 19.5 12 19.5C10.5038 19.5 9.10996 19.0619 7.93972 18.307C8.66519 16.7938 10.2115 15.75 12 15.75C13.7886 15.75 15.3349 16.794 16.0603 18.307ZM17.2545 17.3516C16.2326 15.5027 14.2632 14.25 12 14.25C9.73663 14.25 7.76733 15.5029 6.74545 17.3516C5.3596 15.9907 4.5 14.0958 4.5 12C4.5 7.85786 7.85786 4.5 12 4.5C16.1421 4.5 19.5 7.85786 19.5 12C19.5 14.0958 18.6404 15.9908 17.2545 17.3516ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM12 12C13.2426 12 14.25 10.9926 14.25 9.75C14.25 8.50736 13.2426 7.5 12 7.5C10.7574 7.5 9.75 8.50736 9.75 9.75C9.75 10.9926 10.7574 12 12 12ZM12 13.5C14.0711 13.5 15.75 11.8211 15.75 9.75C15.75 7.67893 14.0711 6 12 6C9.92893 6 8.25 7.67893 8.25 9.75C8.25 11.8211 9.92893 13.5 12 13.5Z"fill="#808080"></path></g></svg></button><div class="more-menu"id="more-menu"><div class="more-menu-item"data-action="share">Share</div><div class="more-menu-item"data-action="exit">Quit</div></div></div>`;
|
||||
setupMoreBtnMenu();
|
||||
setupMobileUIHandlers()
|
||||
}
|
||||
|
||||
// Setup mobile UI event handlers
|
||||
// 设置移动端 UI 事件处理
|
||||
export function setupMobileUIHandlers() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const rightbar = document.getElementById('rightbar');
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileInfoBtn = document.getElementById('mobile-info-btn');
|
||||
const sidebarMask = document.getElementById('mobile-sidebar-mask');
|
||||
const rightbarMask = document.getElementById('mobile-rightbar-mask');
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth <= 768
|
||||
}
|
||||
|
||||
function updateMobileBtnDisplay() {
|
||||
if (isMobile()) {
|
||||
if (mobileMenuBtn) mobileMenuBtn.style.display = 'flex';
|
||||
if (mobileInfoBtn) mobileInfoBtn.style.display = 'flex'
|
||||
} else {
|
||||
if (mobileMenuBtn) mobileMenuBtn.style.display = 'none';
|
||||
if (mobileInfoBtn) mobileInfoBtn.style.display = 'none';
|
||||
if (sidebar) sidebar.classList.remove('mobile-open');
|
||||
if (rightbar) rightbar.classList.remove('mobile-open');
|
||||
if (sidebarMask) sidebarMask.classList.remove('active');
|
||||
if (rightbarMask) rightbarMask.classList.remove('active')
|
||||
}
|
||||
}
|
||||
updateMobileBtnDisplay();
|
||||
window.addEventListener('resize', updateMobileBtnDisplay);
|
||||
if (mobileMenuBtn && sidebar && sidebarMask) {
|
||||
mobileMenuBtn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
sidebar.classList.add('mobile-open');
|
||||
sidebarMask.classList.add('active')
|
||||
};
|
||||
sidebarMask.onclick = function() {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
sidebarMask.classList.remove('active')
|
||||
}
|
||||
}
|
||||
if (mobileInfoBtn && rightbar && rightbarMask) {
|
||||
mobileInfoBtn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
rightbar.classList.add('mobile-open');
|
||||
rightbarMask.classList.add('active')
|
||||
};
|
||||
rightbarMask.onclick = function() {
|
||||
rightbar.classList.remove('mobile-open');
|
||||
rightbarMask.classList.remove('active')
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', function(ev) {
|
||||
if (!isMobile()) return;
|
||||
if (sidebar && sidebar.classList.contains('mobile-open')) {
|
||||
if (!sidebar.contains(ev.target) && ev.target !== mobileMenuBtn) {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
if (sidebarMask) sidebarMask.classList.remove('active')
|
||||
}
|
||||
}
|
||||
if (rightbar && rightbar.classList.contains('mobile-open')) {
|
||||
if (!rightbar.contains(ev.target) && ev.target !== mobileInfoBtn) {
|
||||
rightbar.classList.remove('mobile-open');
|
||||
if (rightbarMask) rightbarMask.classList.remove('active')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Render the user/member list
|
||||
// 渲染用户/成员列表
|
||||
export function renderUserList(updateHeader = false) {
|
||||
const userListEl = $id('member-list');
|
||||
if (!userListEl) return;
|
||||
userListEl.innerHTML = '';
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
if (!rd) return;
|
||||
const me = rd.userList.find(u => u.clientId === rd.myId);
|
||||
const others = rd.userList.filter(u => u.clientId !== rd.myId);
|
||||
if (me) userListEl.appendChild(createUserItem(me, true));
|
||||
others.forEach(u => userListEl.appendChild(createUserItem(u, false)));
|
||||
if (updateHeader) {
|
||||
renderMainHeader()
|
||||
}
|
||||
}
|
||||
|
||||
// Create a user list item
|
||||
// 创建一个用户列表项
|
||||
export function createUserItem(user, isMe) {
|
||||
const div = document.createElement('div');
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
const isPrivateTarget = rd && user.clientId === rd.privateChatTargetId;
|
||||
div.className = 'member' + (isMe ? ' me' : '') + (isPrivateTarget ? ' private-chat-active' : '');
|
||||
const rawName = user.userName || user.username || user.name || '';
|
||||
const safeUserName = escapeHTML(rawName);
|
||||
div.innerHTML = `<span class="avatar"></span><div class="member-info"><div class="member-name">${safeUserName}${isMe?' (me)':''}</div></div>`;
|
||||
const avatarEl = div.querySelector('.avatar');
|
||||
if (avatarEl) {
|
||||
const svg = createAvatarSVG(rawName);
|
||||
const cleanSvg = svg.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
avatarEl.innerHTML = cleanSvg
|
||||
}
|
||||
if (!isMe) {
|
||||
div.onclick = () => togglePrivateChat(user.clientId, safeUserName)
|
||||
}
|
||||
return div
|
||||
}
|
||||
|
||||
// Setup the 'more' button menu
|
||||
// 设置“更多”按钮菜单
|
||||
export function setupMoreBtnMenu() {
|
||||
const btn = $id('more-btn');
|
||||
const menu = $id('more-menu');
|
||||
if (!btn || !menu) return;
|
||||
let menuRect = null;
|
||||
let animating = false;
|
||||
let isMouseMoveBound = false;
|
||||
|
||||
// Mouse move event for closing menu if mouse leaves
|
||||
// 鼠标移出菜单区域时关闭菜单
|
||||
function onMouseMove(ev) {
|
||||
if (!menuRect) return;
|
||||
const mx = ev.clientX,
|
||||
my = ev.clientY;
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const safe = 100;
|
||||
const inMenu = mx >= menuRect.left - safe && mx <= menuRect.right + safe && my >= menuRect.top - safe && my <= menuRect.bottom + safe;
|
||||
const inBtn = mx >= btnRect.left - safe && mx <= btnRect.right + safe && my >= btnRect.top - safe && my <= btnRect.bottom + safe;
|
||||
if (!inMenu && !inBtn) {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Bind mousemove event
|
||||
// 绑定鼠标移动事件
|
||||
function bindMouseMove() {
|
||||
if (!isMouseMoveBound) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
isMouseMoveBound = true
|
||||
}
|
||||
}
|
||||
|
||||
// Unbind mousemove event
|
||||
// 解绑鼠标移动事件
|
||||
function unbindMouseMove() {
|
||||
if (isMouseMoveBound) {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
isMouseMoveBound = false
|
||||
}
|
||||
}
|
||||
|
||||
// Open the menu
|
||||
// 打开菜单
|
||||
function openMenu() {
|
||||
menu.classList.remove('close');
|
||||
menu.classList.add('open');
|
||||
menu.style.display = 'block';
|
||||
menuRect = menu.getBoundingClientRect();
|
||||
bindMouseMove()
|
||||
}
|
||||
|
||||
// Close the menu
|
||||
// 关闭菜单
|
||||
function closeMenu() {
|
||||
if (animating) return;
|
||||
animating = true;
|
||||
menu.classList.remove('open');
|
||||
menu.classList.add('close');
|
||||
setTimeout(() => {
|
||||
if (menu.classList.contains('close')) menu.style.display = 'none';
|
||||
animating = false;
|
||||
unbindMouseMove()
|
||||
}, 180);
|
||||
menuRect = null
|
||||
}
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
if (menu.classList.contains('open')) {
|
||||
closeMenu()
|
||||
} else {
|
||||
openMenu()
|
||||
}
|
||||
};
|
||||
menu.onclick = function(e) {
|
||||
if (e.target.classList.contains('more-menu-item')) {
|
||||
if (e.target.dataset.action === 'share') {
|
||||
if (activeRoomIndex >= 0 && roomsData[activeRoomIndex]) {
|
||||
const rd = roomsData[activeRoomIndex];
|
||||
const room = encodeURIComponent(rd.roomName || '');
|
||||
const pwd = encodeURIComponent(rd.password || '');
|
||||
const url = `${location.origin}${location.pathname}?node=${room}&pwd=${pwd}`;
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
window.addSystemMsg('Share link copied!')
|
||||
}, () => {
|
||||
prompt('Copy failed, url:', url)
|
||||
})
|
||||
} else {
|
||||
prompt('Copy failed, url:', url)
|
||||
}
|
||||
}
|
||||
closeMenu()
|
||||
} else if (e.target.dataset.action === 'exit') {
|
||||
const result = exitRoom();
|
||||
if (!result) {
|
||||
location.reload()
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', function hideMenu(ev) {
|
||||
if (!menu.contains(ev.target) && ev.target !== btn) {
|
||||
closeMenu()
|
||||
}
|
||||
});
|
||||
menu.addEventListener('animationend', function(e) {
|
||||
animating = false
|
||||
});
|
||||
menu.addEventListener('transitionend', function(e) {
|
||||
animating = false
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent space and special character input
|
||||
// 禁止输入空格和特殊字符
|
||||
export function preventSpaceInput(input) {
|
||||
if (!input) return;
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === ' ' || (/[\u0000-\u007f]/.test(e.key) && /[\p{P}\p{S}]/u.test(e.key) && e.key !== "'")) {
|
||||
e.preventDefault()
|
||||
}
|
||||
});
|
||||
input.addEventListener('input', function(e) {
|
||||
input.value = input.value.replace(/[\s\p{P}\p{S}]/gu, function(match) {
|
||||
return match === "'" ? "'" : ''
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Login form submit handler
|
||||
// 登录表单提交处理函数
|
||||
export function loginFormHandler(modal) {
|
||||
return function(e) {
|
||||
e.preventDefault();
|
||||
let userName, roomName, password, btn, roomInput, warnTip;
|
||||
if (modal) {
|
||||
userName = document.getElementById('userName-modal').value.trim();
|
||||
roomName = document.getElementById('roomName-modal').value.trim();
|
||||
password = document.getElementById('password-modal').value.trim();
|
||||
btn = modal.querySelector('.login-btn');
|
||||
roomInput = document.getElementById('roomName-modal')
|
||||
} else {
|
||||
userName = document.getElementById('userName').value.trim();
|
||||
roomName = document.getElementById('roomName').value.trim();
|
||||
password = document.getElementById('password').value.trim();
|
||||
btn = document.querySelector('#login-form .login-btn');
|
||||
roomInput = document.getElementById('roomName')
|
||||
}
|
||||
const exists = roomsData.some(rd => rd.roomName && rd.roomName.toLowerCase() === roomName.toLowerCase());
|
||||
if (roomInput) {
|
||||
roomInput.style.border = '';
|
||||
roomInput.style.background = '';
|
||||
if (roomInput._warnTip) {
|
||||
roomInput.parentNode.removeChild(roomInput._warnTip);
|
||||
roomInput._warnTip = null
|
||||
}
|
||||
}
|
||||
if (exists) {
|
||||
if (roomInput) {
|
||||
roomInput.style.border = '1.5px solid #e74c3c';
|
||||
roomInput.style.background = '#fff6f6';
|
||||
warnTip = document.createElement('div');
|
||||
warnTip.style.color = '#e74c3c';
|
||||
warnTip.style.fontSize = '13px';
|
||||
warnTip.style.marginTop = '4px';
|
||||
warnTip.textContent = 'Node already exists';
|
||||
roomInput.parentNode.appendChild(warnTip);
|
||||
roomInput._warnTip = warnTip;
|
||||
roomInput.focus()
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerText = 'ENTER'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerText = 'Connecting...'
|
||||
}
|
||||
window.joinRoom(userName, roomName, password, modal, function(success) {
|
||||
if (!success && btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerText = 'ENTER'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Open the login modal dialog
|
||||
// 打开登录弹窗
|
||||
export function openLoginModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'login-modal';
|
||||
modal.innerHTML = `<div class="login-modal-bg"></div><div class="login-modal-card"><button class="login-modal-close login-modal-close-abs">×</button><h1>Enter a Node</h1><form id="login-form-modal"><div class="input-group"><label for="userName-modal">Username</label><input id="userName-modal"type="text"autocomplete="username"required minlength="1"maxlength="15"></div><div class="input-group"><label for="roomName-modal">Node Name</label><input id="roomName-modal"type="text"required minlength="1"maxlength="15"></div><div class="input-group"><label for="password-modal">Node Password<span class="optional">(optional)</span></label><input id="password-modal"type="password"autocomplete="off"minlength="1"maxlength="15"></div><button type="submit"class="login-btn">ENTER</button></form></div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('.login-modal-close').onclick = () => modal.remove();
|
||||
preventSpaceInput(modal.querySelector('#userName-modal'));
|
||||
preventSpaceInput(modal.querySelector('#roomName-modal'));
|
||||
preventSpaceInput(modal.querySelector('#password-modal'));
|
||||
const form = modal.querySelector('#login-form-modal');
|
||||
form.addEventListener('submit', loginFormHandler(modal));
|
||||
autofillRoomPwd('userName-modal')
|
||||
}
|
||||
|
||||
// Setup member list tabs
|
||||
// 设置成员列表标签页
|
||||
export function setupTabs() {
|
||||
const tabs = document.getElementById("member-tabs").children;
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
tabs[i].onclick = function() {
|
||||
for (let j = 0; j < tabs.length; j++) tabs[j].classList.remove("active");
|
||||
this.classList.add("active")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Autofill room and password from URL
|
||||
// 从 URL 自动填充房间和密码
|
||||
export function autofillRoomPwd(formPrefix = '') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const room = params.get('node');
|
||||
const pwd = params.get('pwd');
|
||||
if (room) {
|
||||
const roomInput = document.getElementById(formPrefix + 'roomName');
|
||||
if (roomInput) {
|
||||
roomInput.value = decodeURIComponent(room);
|
||||
roomInput.readOnly = true;
|
||||
roomInput.style.background = '#f5f5f5'
|
||||
}
|
||||
}
|
||||
if (pwd) {
|
||||
const pwdInput = document.getElementById(formPrefix + 'password');
|
||||
if (pwdInput) {
|
||||
pwdInput.value = decodeURIComponent(pwd);
|
||||
pwdInput.readOnly = true;
|
||||
pwdInput.style.background = '#f5f5f5'
|
||||
}
|
||||
}
|
||||
if (room || pwd) {
|
||||
window.history.replaceState({}, '', location.pathname)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Import dicebear avatar libraries
|
||||
// 导入 dicebear 头像库
|
||||
import * as dicebearCore from '@dicebear/core';
|
||||
import * as dicebearMicah from '@dicebear/micah';
|
||||
// Predefined background colors
|
||||
// 预设背景色数组
|
||||
const bgColors = ["f87171", "fb923c", "09acf4", "fb923c", "f472b6", "a78bfa", "34d399"];
|
||||
// Pick a color based on seed string
|
||||
// 根据种子字符串选择颜色
|
||||
function pickColor(seed) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return bgColors[Math.abs(hash) % bgColors.length]
|
||||
}
|
||||
// Create SVG avatar for user name
|
||||
// 为用户名生成 SVG 头像
|
||||
export function createAvatarSVG(userName) {
|
||||
return dicebearCore.createAvatar(dicebearMicah, {
|
||||
seed: userName,
|
||||
baseColor: ["f7e1c3", "f9c9b6", "f2d6cb", "f8ce8e", "eac393"],
|
||||
backgroundColor: [pickColor(userName)]
|
||||
}).toString()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Query single DOM element
|
||||
// 查询单个 DOM 元素
|
||||
export function $(selector, parent = document) {
|
||||
return parent.querySelector(selector)
|
||||
}
|
||||
// Query multiple DOM elements
|
||||
// 查询多个 DOM 元素
|
||||
export function $$(selector, parent = document) {
|
||||
return parent.querySelectorAll(selector)
|
||||
}
|
||||
// Get element by ID
|
||||
// 通过 ID 获取元素
|
||||
export function $id(id) {
|
||||
return document.getElementById(id)
|
||||
}
|
||||
// Create a DOM element with attributes and content
|
||||
// 创建带属性和内容的 DOM 元素
|
||||
export function createElement(tag, attrs = {}, content = '') {
|
||||
const el = document.createElement(tag);
|
||||
Object.entries(attrs).forEach(([key, value]) => {
|
||||
if (key === 'class' || key === 'className') {
|
||||
el.className = value
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(el.style, value)
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value)
|
||||
} else {
|
||||
el.setAttribute(key, value)
|
||||
}
|
||||
});
|
||||
if (typeof content === 'string') {
|
||||
el.innerHTML = content
|
||||
} else if (content instanceof Element) {
|
||||
el.appendChild(content)
|
||||
}
|
||||
return el
|
||||
}
|
||||
// Add event listener
|
||||
// 添加事件监听器
|
||||
export function on(target, event, handler, options) {
|
||||
const el = typeof target === 'string' ? $(target) : target;
|
||||
if (el) el.addEventListener(event, handler, options)
|
||||
}
|
||||
// Remove event listener
|
||||
// 移除事件监听器
|
||||
export function off(target, event, handler) {
|
||||
const el = typeof target === 'string' ? $(target) : target;
|
||||
if (el) el.removeEventListener(event, handler)
|
||||
}
|
||||
// Add class(es) to element
|
||||
// 为元素添加类名
|
||||
export function addClass(el, ...classNames) {
|
||||
el.classList.add(...classNames)
|
||||
}
|
||||
// Remove class(es) from element
|
||||
// 移除元素的类名
|
||||
export function removeClass(el, ...classNames) {
|
||||
el.classList.remove(...classNames)
|
||||
}
|
||||
// Toggle class on element
|
||||
// 切换元素的类名
|
||||
export function toggleClass(el, className, force) {
|
||||
el.classList.toggle(className, force)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Import DOM helpers
|
||||
// 导入 DOM 辅助函数
|
||||
import {
|
||||
$,
|
||||
on
|
||||
} from './util.dom.js';
|
||||
import 'emoji-picker-element';
|
||||
// Add emoji picker styles to document
|
||||
// 向文档添加 emoji 选择器样式
|
||||
const addEmojiPickerStyles = () => {
|
||||
if (document.querySelector('#emoji-picker-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'emoji-picker-styles';
|
||||
style.textContent = `emoji-picker{--background:#fff;--border-color:rgba(0,0,0,0.1);--border-radius:10px;--emoji-padding:0.4rem;--category-emoji-size:1.2rem;--font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;position:absolute;bottom:60px;left:22px;z-index:5;box-shadow:0 3px 12px rgba(0,0,0,0.15);animation:emoji-panel-fade-in 0.18s;width:320px;display:none}@keyframes emoji-panel-fade-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}`;
|
||||
document.head.appendChild(style)
|
||||
};
|
||||
// Setup emoji picker for chat input
|
||||
// 为聊天输入框设置 emoji 选择器
|
||||
export function setupEmojiPicker({
|
||||
btnSelector = '.chat-emoji-btn',
|
||||
inputSelector = '.input-message-input'
|
||||
} = {}) {
|
||||
const btn = $(btnSelector);
|
||||
const input = $(inputSelector);
|
||||
if (!btn || !input) return;
|
||||
try {
|
||||
addEmojiPickerStyles();
|
||||
const oldPicker = $('emoji-picker', btn.parentNode);
|
||||
if (oldPicker) oldPicker.remove();
|
||||
const picker = document.createElement('emoji-picker');
|
||||
picker.style.display = 'none';
|
||||
btn.parentNode.style.position = 'relative';
|
||||
btn.parentNode.appendChild(picker);
|
||||
// Emoji click event
|
||||
// 监听 emoji 点击事件
|
||||
picker.addEventListener('emoji-click', event => {
|
||||
insertEmoji(input, event.detail.unicode);
|
||||
picker.style.display = 'none'
|
||||
});
|
||||
// Button click toggles picker
|
||||
// 按钮点击切换选择器显示
|
||||
on(btn, 'click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
picker.style.display = picker.style.display === 'none' ? 'block' : 'none'
|
||||
});
|
||||
// Hide picker when clicking outside
|
||||
// 点击外部隐藏选择器
|
||||
on(document, 'click', (ev) => {
|
||||
if (!picker.contains(ev.target) && ev.target !== btn) {
|
||||
picker.style.display = 'none'
|
||||
}
|
||||
});
|
||||
console.log('Emoji picker initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize emoji picker:', error)
|
||||
}
|
||||
}
|
||||
// Insert emoji into input at cursor
|
||||
// 在光标处插入 emoji 到输入框
|
||||
function insertEmoji(input, emoji) {
|
||||
input.focus();
|
||||
if (document.getSelection && window.getSelection) {
|
||||
let sel = window.getSelection();
|
||||
if (!sel.rangeCount) return;
|
||||
let range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(emoji));
|
||||
range.collapse(false);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range)
|
||||
} else {
|
||||
input.innerText += emoji
|
||||
}
|
||||
input.dispatchEvent(new Event('input'))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Import DOM helpers
|
||||
// 导入 DOM 辅助函数
|
||||
import {
|
||||
$,
|
||||
on,
|
||||
createElement
|
||||
} from './util.dom.js';
|
||||
// Process and compress image file
|
||||
// 处理并压缩图片文件
|
||||
export async function processImage(file, callback) {
|
||||
// Create image element for loading file
|
||||
// 创建图片元素用于加载文件
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
const maxW = 1280, // Max width
|
||||
maxH = 1280; // 最大宽度/高度
|
||||
let w = img.naturalWidth,
|
||||
h = img.naturalHeight;
|
||||
// Resize if too large
|
||||
// 如果图片过大则缩放
|
||||
if (w > maxW || h > maxH) {
|
||||
const scale = Math.min(maxW / w, maxH / h);
|
||||
w = Math.round(w * scale);
|
||||
h = Math.round(h * scale)
|
||||
}
|
||||
// Create canvas for drawing image
|
||||
// 创建画布用于绘制图片
|
||||
const canvas = createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
let dataUrl;
|
||||
// Export as webp
|
||||
// 导出为 webp
|
||||
dataUrl = canvas.toDataURL('image/webp', 0.80);
|
||||
callback(dataUrl)
|
||||
};
|
||||
// Read file as data URL
|
||||
// 以 dataURL 方式读取文件
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
img.src = e.target.result
|
||||
};
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
// Translate message key
|
||||
// 翻译消息 key
|
||||
function t(key) {
|
||||
const messages = {
|
||||
tooLarge: 'Image is too large (over 5MB)', // 图片过大(超过5MB)
|
||||
};
|
||||
return messages[key] || key
|
||||
}
|
||||
// Setup image send logic for UI
|
||||
// 设置图片发送相关 UI 逻辑
|
||||
export function setupImageSend({
|
||||
inputSelector,
|
||||
attachBtnSelector,
|
||||
fileInputSelector,
|
||||
onSend
|
||||
}) {
|
||||
const input = $(inputSelector);
|
||||
const attachBtn = $(attachBtnSelector);
|
||||
const fileInput = $(fileInputSelector);
|
||||
if (fileInput) fileInput.setAttribute('accept', 'image/*');
|
||||
if (attachBtn && fileInput) {
|
||||
// Click attach triggers file input
|
||||
// 点击附件按钮触发文件选择
|
||||
on(attachBtn, 'click', () => fileInput.click());
|
||||
// Handle file input change
|
||||
// 处理文件选择变化
|
||||
on(fileInput, 'change', async function() {
|
||||
if (!fileInput.files || !fileInput.files.length) return;
|
||||
const file = fileInput.files[0];
|
||||
// Only allow image files
|
||||
// 只允许图片文件
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
// Check file size
|
||||
// 检查文件大小
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert(t('tooLarge'));
|
||||
return
|
||||
}
|
||||
processImage(file, (dataUrl) => {
|
||||
if (typeof onSend === 'function') onSend(dataUrl)
|
||||
});
|
||||
fileInput.value = ''
|
||||
})
|
||||
}
|
||||
if (input) {
|
||||
// Paste image from clipboard
|
||||
// 从剪贴板粘贴图片
|
||||
on(input, 'paste', function(e) {
|
||||
if (!e.clipboardData) return;
|
||||
for (const item of e.clipboardData.items) {
|
||||
// Only handle image type
|
||||
// 只处理图片类型
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
// Check file size
|
||||
// 检查文件大小
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert(t('tooLarge'));
|
||||
continue
|
||||
}
|
||||
processImage(file, (dataUrl) => {
|
||||
if (typeof onSend === 'function') onSend(dataUrl)
|
||||
});
|
||||
e.preventDefault();
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Import DOM utility functions
|
||||
// 导入 DOM 工具函数
|
||||
import {
|
||||
$,
|
||||
$$,
|
||||
$id,
|
||||
createElement,
|
||||
on,
|
||||
off,
|
||||
addClass,
|
||||
removeClass
|
||||
} from './util.dom.js';
|
||||
// Default settings
|
||||
// 默认设置
|
||||
const DEFAULT_SETTINGS = {
|
||||
notify: false,
|
||||
sound: false
|
||||
};
|
||||
|
||||
// Load settings from localStorage
|
||||
// 从 localStorage 加载设置
|
||||
function loadSettings() {
|
||||
let s = localStorage.getItem('settings');
|
||||
try {
|
||||
s = s ? JSON.parse(s) : {}
|
||||
} catch {
|
||||
s = {}
|
||||
}
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...s
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings to localStorage
|
||||
// 保存设置到 localStorage
|
||||
function saveSettings(settings) {
|
||||
const {
|
||||
notify,
|
||||
sound
|
||||
} = settings;
|
||||
localStorage.setItem('settings', JSON.stringify({
|
||||
notify,
|
||||
sound
|
||||
}))
|
||||
}
|
||||
|
||||
// Apply settings to the document
|
||||
// 应用设置到文档
|
||||
function applySettings(settings) {
|
||||
document.documentElement.lang = 'en'
|
||||
}
|
||||
|
||||
// Ask for browser notification permission
|
||||
// 请求浏览器通知权限
|
||||
function askNotificationPermission(callback) {
|
||||
if (Notification.requestPermission.length === 0) {
|
||||
Notification.requestPermission().then(callback)
|
||||
} else {
|
||||
Notification.requestPermission(callback)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the settings panel UI
|
||||
// 设置设置面板 UI
|
||||
function setupSettingsPanel() {
|
||||
let panel = $id('settings-panel');
|
||||
if (!panel) {
|
||||
panel = createElement('div', {
|
||||
id: 'settings-panel',
|
||||
class: 'settings-panel'
|
||||
}, `<div class="settings-panel-card"><div class="settings-title">Settings</div><div class="settings-item"><span>Desktop Notification</span><label class="switch"><input type="checkbox"id="settings-notify"><span class="slider"></span></label></div><div class="settings-item"><span>Sound Notification</span><label class="switch"><input type="checkbox"id="settings-sound"><span class="slider"></span></label></div></div>`);
|
||||
document.body.appendChild(panel)
|
||||
}
|
||||
const settings = loadSettings();
|
||||
const notifyCheckbox = $('#settings-notify', panel);
|
||||
const soundCheckbox = $('#settings-sound', panel);
|
||||
if (notifyCheckbox) notifyCheckbox.checked = !!settings.notify;
|
||||
if (soundCheckbox) soundCheckbox.checked = !!settings.sound;
|
||||
on(notifyCheckbox, 'change', e => {
|
||||
const checked = e.target.checked;
|
||||
if (checked) {
|
||||
if (!('Notification' in window)) {
|
||||
alert('Notifications are not supported by your browser.');
|
||||
e.target.checked = false;
|
||||
return
|
||||
}
|
||||
askNotificationPermission(permission => {
|
||||
if (permission === 'granted') {
|
||||
settings.notify = true;
|
||||
settings.sound = false;
|
||||
if (soundCheckbox) soundCheckbox.checked = false;
|
||||
saveSettings(settings);
|
||||
applySettings(settings);
|
||||
new Notification('Notifications enabled', {
|
||||
body: 'You will receive alerts here.'
|
||||
})
|
||||
} else {
|
||||
settings.notify = false;
|
||||
e.target.checked = false;
|
||||
saveSettings(settings);
|
||||
applySettings(settings);
|
||||
alert('Please allow notifications in your browser settings.')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
settings.notify = false;
|
||||
saveSettings(settings);
|
||||
applySettings(settings)
|
||||
}
|
||||
});
|
||||
on(soundCheckbox, 'change', e => {
|
||||
settings.sound = e.target.checked;
|
||||
if (settings.sound) {
|
||||
settings.notify = false;
|
||||
if (notifyCheckbox) notifyCheckbox.checked = false;
|
||||
}
|
||||
saveSettings(settings);
|
||||
applySettings(settings)
|
||||
});
|
||||
}
|
||||
|
||||
// Open the settings panel
|
||||
// 打开设置面板
|
||||
function openSettingsPanel() {
|
||||
setupSettingsPanel();
|
||||
const panel = $id('settings-panel');
|
||||
const btn = $id('settings-btn');
|
||||
if (!btn || !panel) return;
|
||||
if (panel.style.display === 'block') return;
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
panel.style.display = 'block';
|
||||
const card = $('.settings-panel-card', panel);
|
||||
if (!card) return;
|
||||
card.style.position = 'fixed';
|
||||
card.style.left = btnRect.left + 'px';
|
||||
card.style.top = (btnRect.bottom + 8) + 'px';
|
||||
card.style.transform = 'translateX(0)';
|
||||
removeClass(card, 'close-anim');
|
||||
addClass(card, 'open-anim');
|
||||
|
||||
function onMouseMove(ev) {
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const safe = 60;
|
||||
const mx = ev.clientX,
|
||||
my = ev.clientY;
|
||||
const inCard = mx >= cardRect.left - safe && mx <= cardRect.right + safe && my >= cardRect.top - safe && my <= cardRect.bottom + safe;
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const inBtn = mx >= btnRect.left - safe && mx <= btnRect.right + safe && my >= btnRect.top - safe && my <= btnRect.bottom + safe;
|
||||
if (!inCard && !inBtn) {
|
||||
closeSettingsPanel()
|
||||
}
|
||||
}
|
||||
|
||||
function bindMouseMove() {
|
||||
if (!panel._mousemoveBound) {
|
||||
on(window, 'mousemove', onMouseMove);
|
||||
panel._mousemoveBound = true
|
||||
}
|
||||
}
|
||||
|
||||
function unbindMouseMove() {
|
||||
if (panel._mousemoveBound) {
|
||||
off(window, 'mousemove', onMouseMove);
|
||||
panel._mousemoveBound = false
|
||||
}
|
||||
}
|
||||
bindMouseMove();
|
||||
panel._unbind = unbindMouseMove
|
||||
}
|
||||
|
||||
// Close the settings panel
|
||||
// 关闭设置面板
|
||||
function closeSettingsPanel() {
|
||||
const panel = $id('settings-panel');
|
||||
if (!panel) return;
|
||||
const card = $('.settings-panel-card', panel);
|
||||
if (!card) return;
|
||||
removeClass(card, 'open-anim');
|
||||
addClass(card, 'close-anim');
|
||||
if (panel._unbind) panel._unbind();
|
||||
setTimeout(() => {
|
||||
panel.style.display = 'none';
|
||||
removeClass(card, 'close-anim')
|
||||
}, 180)
|
||||
}
|
||||
|
||||
// Initialize settings on page load
|
||||
// 页面加载时初始化设置
|
||||
function initSettings() {
|
||||
const settings = loadSettings();
|
||||
applySettings(settings)
|
||||
}
|
||||
|
||||
// Maximum notification text length
|
||||
// 通知文本最大长度
|
||||
const MAX_NOTIFY_TEXT_LEN = 100;
|
||||
|
||||
// Truncate text for notifications
|
||||
// 截断通知文本
|
||||
function truncateText(text) {
|
||||
return text.length > MAX_NOTIFY_TEXT_LEN ? text.slice(0, MAX_NOTIFY_TEXT_LEN) + '...' : text
|
||||
}
|
||||
|
||||
// Play sound notification
|
||||
// 播放声音通知
|
||||
function playSoundNotification() {
|
||||
try {
|
||||
const ctx = new(window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.frequency.value = 1000;
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
osc.stop();
|
||||
ctx.close()
|
||||
}, 600)
|
||||
} catch (e) {
|
||||
console.error('Sound notification failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Show desktop notification
|
||||
// 显示桌面通知
|
||||
function showDesktopNotification(roomName, text, msgType, sender) {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
let body;
|
||||
const senderPrefix = sender ? `${sender}:` : '';
|
||||
if (msgType === 'image' || msgType === 'private image') {
|
||||
body = `${senderPrefix}[image]`;
|
||||
if (msgType === 'private image') {
|
||||
body = `(Private)${body}`
|
||||
}
|
||||
} else if (msgType === 'text' || msgType === 'private text') {
|
||||
body = `${senderPrefix}${truncateText(text)}`;
|
||||
if (msgType === 'private text') {
|
||||
body = `(Private)${body}`
|
||||
}
|
||||
} else {
|
||||
body = truncateText(text)
|
||||
}
|
||||
new Notification(`#${roomName}`, {
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
// Notify message entry point
|
||||
// 通知消息主入口
|
||||
export function notifyMessage(roomName, msgType, text, sender) {
|
||||
const settings = loadSettings();
|
||||
if (settings.notify) {
|
||||
showDesktopNotification(roomName, text, msgType, sender)
|
||||
} else if (settings.sound) {
|
||||
playSoundNotification()
|
||||
}
|
||||
}
|
||||
export {
|
||||
openSettingsPanel,
|
||||
closeSettingsPanel,
|
||||
initSettings
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// Escape HTML special characters
|
||||
// 转义 HTML 特殊字符
|
||||
export function escapeHTML(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.replace(/[&<>"']/g, function(c) {
|
||||
return {
|
||||
'&': '&', // & to & // & 转为 &
|
||||
'<': '<', // < to < // < 转为 <
|
||||
'>': '>', // > to > // > 转为 >
|
||||
'"': '"', // " to " // " 转为 "
|
||||
'\'': ''' // ' to ' // ' 转为 '
|
||||
} [c]
|
||||
})
|
||||
}
|
||||
// Convert text to HTML, preserving line breaks
|
||||
// 将文本转换为 HTML,保留换行符
|
||||
export function textToHTML(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
return escapeHTML(text).replace(/\n/g, '<br>')
|
||||
}
|
||||
Reference in new issue
Block a user