975 lines
33 KiB
JavaScript
975 lines
33 KiB
JavaScript
import { processImage, setupImageSend } from './util.image.js';
|
|
import { createAvatarSVG } from './util.avatar.js';
|
|
import { setupEmojiPicker } from './util.emoji.js';
|
|
import { openSettingsPanel, closeSettingsPanel, initSettings, notifyMessage } from './util.settings.js';
|
|
import './NodeCrypt.js';
|
|
|
|
/*const config = {
|
|
rsaPublic: 'CHATCRYPT_PUBLIC_KEY',
|
|
wsAddress: '/ws',
|
|
debug: true
|
|
};
|
|
*/
|
|
const config = {
|
|
rsaPublic: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqdizNGtbEQh7WYJDDjzNPMgoGByUXEM3DHNEgstFp83xAPfEGRB6IsoEOwE2ulmL3LXwu7951Tu7qo2iM+4onAMazgg1H9EDrg4FFRv+X8jeTH1u/97Uhze1+WN54Kq3sp9EtZ5nQkBG1AjQjs5HCKzbL2KLevKXhkNZ5iuhmyoC5uCeAEeHGWDaLct3yovP5j6TG9+9/MlRZBlLQUWxvkxYAXBvAgnVpQAscXqIHI5qfPufuG7ecEkjv5At5KpJCGCqxrc54DDS9iOaQDWLBM/wIOIAq5zVTXaeYpxcK8u/OjYiD2qY7RtQAes46PoQjGOJyhY8Ln/R8wKsgR3VXwIDAQAB',
|
|
wsAddress: 'wss://nodecrypt.tech/ws',
|
|
debug: true
|
|
};
|
|
|
|
// 多房间状态管理
|
|
let roomsData = [];
|
|
let activeRoomIndex = -1;
|
|
function getNewRoomData() {
|
|
return { roomName: '', userList: [], userMap: {}, myId: null, myUserName: '', chat: null, messages: [], prevUserList: [], knownUserIds: new Set(), unreadCount: 0 };
|
|
}
|
|
// 切换房间并恢复上下文,更新 UI
|
|
function switchRoom(index) {
|
|
if (index < 0 || index >= roomsData.length) return;
|
|
activeRoomIndex = index;
|
|
const rd = roomsData[index];
|
|
// 清零未读数
|
|
if (typeof rd.unreadCount === 'number') rd.unreadCount = 0;
|
|
// 恢复 sidebar-username、sidebar-user-avatar id
|
|
const sidebarUsername = document.getElementById('sidebar-username');
|
|
if (sidebarUsername) sidebarUsername.textContent = rd.myUserName;
|
|
setSidebarAvatar(rd.myUserName);
|
|
renderRooms(index);
|
|
renderMainHeader();
|
|
renderUserList();
|
|
renderChatArea();
|
|
}
|
|
|
|
// 渲染当前房间消息区
|
|
function renderChatArea() {
|
|
const chatArea = document.getElementById('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);
|
|
});
|
|
// 保证表情选择器挂载
|
|
if (window.setupEmojiPicker) window.setupEmojiPicker();
|
|
}
|
|
|
|
// 简单转义,防止XSS
|
|
function escapeHTML(str) {
|
|
return str.replace(/[&<>"']/g, function (c) {
|
|
return {'&':'&','<':'<','>':'>','"':'"','\'':'''}[c];
|
|
});
|
|
}
|
|
|
|
// 渲染在线用户列表(右侧Members)
|
|
function renderUserList() {
|
|
const userListEl = document.getElementById('member-list');
|
|
userListEl.innerHTML = '';
|
|
const rd = roomsData[activeRoomIndex];
|
|
if (!rd) return;
|
|
let me = rd.userList.find(u => u.clientId === rd.myId);
|
|
let others = rd.userList.filter(u => u.clientId !== rd.myId);
|
|
if (me) userListEl.appendChild(createUserItem(me, true));
|
|
others.forEach(u => userListEl.appendChild(createUserItem(u, false)));
|
|
renderMainHeader(); // 在线用户变化时刷新
|
|
}
|
|
function createUserItem(user, isMe) {
|
|
let div = document.createElement('div');
|
|
div.className = 'member' + (isMe ? ' me' : '');
|
|
// 兼容 userName/username/name
|
|
const rawName = user.userName || user.username || user.name || '';
|
|
const safeUserName = escapeHTML(rawName);
|
|
// 头像 SVG
|
|
createAvatarSVG(rawName).then(svg => {
|
|
div.querySelector('.avatar').innerHTML = svg;
|
|
});
|
|
div.innerHTML = `
|
|
<span class="avatar"></span>
|
|
<div class="member-info">
|
|
<div class="member-name">${safeUserName}${isMe ? ' (我)' : ''}</div>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
// 处理服务器推送的在线用户列表
|
|
function handleClientList(idx, list, selfId) {
|
|
const rd = roomsData[idx];
|
|
if (!rd) return;
|
|
// 记录旧用户ID集合
|
|
const oldUserIds = new Set((rd.userList || []).map(u => u.clientId));
|
|
// 新用户ID集合
|
|
const newUserIds = new Set(list.map(u => u.clientId));
|
|
// 检查退出用户(旧有,新没有),并调用 handleClientLeft
|
|
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();
|
|
renderMainHeader(); // 刷新顶部信息栏
|
|
}
|
|
// 初始化计数
|
|
rd.initCount = (rd.initCount || 0) + 1;
|
|
// 收到 2 次列表后标记初始化完成,并填充已知用户集合
|
|
if (rd.initCount === 2) {
|
|
rd.isInitialized = true;
|
|
// 基准用户集合
|
|
rd.knownUserIds = new Set(list.map(u => u.clientId));
|
|
}
|
|
}
|
|
|
|
// 调整 handleClientSecured: 立即更新UI,仅在初始化完成后才处理加入提示
|
|
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;
|
|
}
|
|
// 无论初始化状态如何,始终刷新当前房间UI
|
|
if (activeRoomIndex === idx) {
|
|
renderUserList();
|
|
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);
|
|
// 新增:系统/声音通知
|
|
notifyMessage(rd.roomName, 'system', msg);
|
|
}
|
|
}
|
|
|
|
// 用户下线
|
|
function handleClientLeft(idx, clientId) {
|
|
const rd = roomsData[idx];
|
|
if (!rd) return;
|
|
const user = rd.userMap[clientId];
|
|
// 退出提示逻辑:不再依赖 isInitialized,任何时候都提示
|
|
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();
|
|
}
|
|
}
|
|
|
|
function setStatus(text) {
|
|
let statusBar = document.getElementById('status-bar');
|
|
if (!statusBar) {
|
|
statusBar = document.createElement('div');
|
|
statusBar.id = 'status-bar';
|
|
statusBar.style = 'color:green;padding:4px 10px;font-size:13px;';
|
|
document.body.appendChild(statusBar);
|
|
}
|
|
statusBar.innerText = text;
|
|
}
|
|
|
|
function addMsg(text, isHistory = false, msgType = 'text', timestamp = null) {
|
|
let ts;
|
|
if (isHistory) {
|
|
ts = timestamp;
|
|
} else {
|
|
ts = timestamp || Date.now();
|
|
if (activeRoomIndex >= 0) {
|
|
roomsData[activeRoomIndex].messages.push({ type: 'me', text, msgType, timestamp: ts });
|
|
}
|
|
}
|
|
// 只用传入的 timestamp,不再 fallback 到 Date.now(),避免多余逻辑
|
|
if (!ts) return;
|
|
const chatArea = document.getElementById('chat-area');
|
|
if (!chatArea) return;
|
|
const div = document.createElement('div');
|
|
div.className = 'bubble me';
|
|
const date = new Date(ts);
|
|
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
|
let contentHtml = '';
|
|
if (msgType === 'image' && text.startsWith('data:image/')) {
|
|
contentHtml = `<img src="${text}" alt="image" class="bubble-img">`;
|
|
} else {
|
|
const safeText = escapeHTML(text).replace(/\n/g, '<br>');
|
|
contentHtml = safeText;
|
|
}
|
|
div.innerHTML = `<span class="bubble-content">${contentHtml}</span><span class="bubble-meta">${time}</span>`;
|
|
chatArea.appendChild(div);
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
if (!userName) userName = 'Anonymous';
|
|
let ts;
|
|
if (isHistory) {
|
|
ts = timestamp;
|
|
} else {
|
|
ts = timestamp || Date.now();
|
|
if (activeRoomIndex >= 0) {
|
|
roomsData[activeRoomIndex].messages.push({ type: 'other', text: msg, userName, avatar, msgType, timestamp: ts });
|
|
}
|
|
}
|
|
if (!ts) return;
|
|
const chatArea = document.getElementById('chat-area');
|
|
if (!chatArea) return;
|
|
const bubbleWrap = document.createElement('div');
|
|
bubbleWrap.className = 'bubble-other-wrap';
|
|
let contentHtml = '';
|
|
if (msgType === 'image' && msg.startsWith('data:image/')) {
|
|
contentHtml = `<img src="${msg}" alt="image" class="bubble-img">`;
|
|
} else {
|
|
const safeMsg = escapeHTML(msg).replace(/\n/g, '<br>');
|
|
contentHtml = safeMsg;
|
|
}
|
|
const safeUserName = escapeHTML(userName);
|
|
const date = new Date(ts);
|
|
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
|
|
bubbleWrap.innerHTML = `
|
|
<span class="avatar"></span>
|
|
<div class="bubble-other-main">
|
|
<div class="bubble other">
|
|
<div class="bubble-other-name">${safeUserName}</div>
|
|
<span class="bubble-content">${contentHtml}</span>
|
|
<span class="bubble-meta">${time}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
createAvatarSVG(userName).then(svg => {
|
|
bubbleWrap.querySelector('.avatar').innerHTML = svg;
|
|
});
|
|
chatArea.appendChild(bubbleWrap);
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|
}
|
|
|
|
// 添加系统信息消息
|
|
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 = document.getElementById('chat-area');
|
|
if (!chatArea) return;
|
|
const safeText = escapeHTML(text).replace(/\n/g, '<br>');
|
|
const div = document.createElement('div');
|
|
div.className = 'bubble system';
|
|
div.innerHTML = `<span class="bubble-content">${safeText}</span>`;
|
|
chatArea.appendChild(div);
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|
}
|
|
|
|
// 在控制台中添加全局方法
|
|
window.sendSystemMsg = function(message) {
|
|
addSystemMsg(message);
|
|
};
|
|
|
|
|
|
// 渲染房间列表(动态多房间)
|
|
function renderRooms(activeId = 0) {
|
|
const roomList = document.getElementById('room-list');
|
|
roomList.innerHTML = '';
|
|
roomsData.forEach((rd, i) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'room' + (i === activeId ? ' active' : '');
|
|
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}
|
|
`;
|
|
div.onclick = () => switchRoom(i);
|
|
roomList.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// 登录表单提交处理
|
|
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...';
|
|
}
|
|
joinRoom(userName, roomName, password, modal, function(success) {
|
|
if (!success && btn) {
|
|
btn.disabled = false;
|
|
btn.innerText = 'ENTER';
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
function setSidebarAvatar(userName) {
|
|
if (!userName) return;
|
|
createAvatarSVG(userName).then(svg => {
|
|
const el = document.getElementById('sidebar-user-avatar');
|
|
if (el) el.innerHTML = svg;
|
|
});
|
|
}
|
|
|
|
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 = document.getElementById('sidebar-username');
|
|
if (sidebarUsername) sidebarUsername.textContent = userName;
|
|
setSidebarAvatar(userName);
|
|
// 初始化 ChatCrypt
|
|
let closed = false;
|
|
const callbacks = {
|
|
onServerClosed: () => {
|
|
setStatus('Node connection closed');
|
|
if (onResult && !closed) { closed = true; onResult(false); }
|
|
},
|
|
onServerSecured: () => {
|
|
setStatus('Secure connection to node');
|
|
// 连接成功,隐藏登录界面或关闭modal
|
|
if (modal) modal.remove();
|
|
else document.getElementById('login-container').style.display = 'none';
|
|
document.getElementById('chat-container').style.display = '';
|
|
if (onResult && !closed) { closed = true; onResult(true); }
|
|
},
|
|
onClientSecured: (user) => handleClientSecured(idx, user),
|
|
onClientList: (list, selfId) => handleClientList(idx, list, selfId),
|
|
onClientLeft: (clientId) => handleClientLeft(idx, clientId),
|
|
onClientMessage: (msg) => {
|
|
if (msg.userName === newRd.myUserName) return;
|
|
// 判断消息类型
|
|
let msgType = msg.type || (msg.data && msg.data.startsWith('data:image/') ? 'image' : 'text');
|
|
// 修正:如果msg.userName缺失,尝试从userMap查找
|
|
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()
|
|
});
|
|
// 通知消息,无论当前房间是否激活
|
|
notifyMessage(newRd.roomName, msgType, msg.data, realUserName);
|
|
// 未读数逻辑
|
|
if (activeRoomIndex !== idx) {
|
|
roomsData[idx].unreadCount = (roomsData[idx].unreadCount || 0) + 1;
|
|
renderRooms(activeRoomIndex);
|
|
} else {
|
|
renderChatArea();
|
|
}
|
|
}
|
|
};
|
|
const chatInst = new window.ChatCrypt(config, callbacks);
|
|
chatInst.setCredentials(userName, roomName, password);
|
|
chatInst.connect();
|
|
roomsData[idx].chat = chatInst;
|
|
}
|
|
|
|
// 禁止输入框输入空格的工具函数
|
|
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 === "'" ? "'" : '';
|
|
});
|
|
});
|
|
}
|
|
|
|
// 打开新房间登录模态
|
|
function openLoginModal() {
|
|
const loginContainer = document.getElementById('login-container');
|
|
// 创建一个新的modal,内容copy(不是复用)初始登录页面
|
|
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'));
|
|
// 表单提交逻辑,复用loginFormHandler但传入modal
|
|
const form = modal.querySelector('#login-form-modal');
|
|
form.addEventListener('submit', loginFormHandler(modal));
|
|
}
|
|
|
|
// 渲染主面板房间头部(UI占位)
|
|
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);
|
|
document.getElementById("main-header").innerHTML = `
|
|
<div class="main-header-flex">
|
|
<div class="group-title group-title-bold">#${safeRoomName}</div>
|
|
<span class="main-header-members">${onlineCount} members</span>
|
|
</div>
|
|
<div class="main-header-actions">
|
|
<button class="more-btn" id="more-btn" aria-label="More">
|
|
<span class="more-btn-dot"></span>
|
|
<span class="more-btn-dot"></span>
|
|
<span class="more-btn-dot"></span>
|
|
</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();
|
|
}
|
|
|
|
// 清空消息区(UI占位)
|
|
function clearChat() {
|
|
document.getElementById("chat-area").innerHTML = "";
|
|
}
|
|
|
|
// 右侧tab切换
|
|
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");
|
|
// 这里只做UI切换,实际功能由你开发
|
|
}
|
|
}
|
|
}
|
|
|
|
// Telegram风格输入框占位符逻辑
|
|
function autoGrowInput() {
|
|
const input = document.querySelector('.input-message-input');
|
|
if (!input) return;
|
|
input.style.height = 'auto';
|
|
input.style.height = input.scrollHeight + 'px';
|
|
}
|
|
|
|
function setupInputPlaceholder() {
|
|
const input = document.querySelector('.input-message-input');
|
|
const placeholder = document.querySelector('.input-field-placeholder');
|
|
function checkEmpty() {
|
|
// 判断内容是否为空(忽略所有空白、<br>、 等)
|
|
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();
|
|
}
|
|
input.addEventListener('input', checkEmpty);
|
|
input.addEventListener('blur', checkEmpty);
|
|
input.addEventListener('focus', checkEmpty);
|
|
// 初始化
|
|
checkEmpty();
|
|
autoGrowInput();
|
|
}
|
|
|
|
// 更多按钮菜单交互
|
|
function setupMoreBtnMenu() {
|
|
const btn = document.getElementById('more-btn');
|
|
const menu = document.getElementById('more-menu');
|
|
if (!btn || !menu) return;
|
|
let menuRect = null;
|
|
let animating = false;
|
|
let isMouseMoveBound = false;
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
function bindMouseMove() {
|
|
if (!isMouseMoveBound) {
|
|
window.addEventListener('mousemove', onMouseMove);
|
|
isMouseMoveBound = true;
|
|
}
|
|
}
|
|
function unbindMouseMove() {
|
|
if (!isMouseMoveBound) {
|
|
window.removeEventListener('mousemove', onMouseMove);
|
|
isMouseMoveBound = false;
|
|
}
|
|
}
|
|
|
|
function openMenu() {
|
|
menu.classList.remove('close');
|
|
menu.classList.add('open');
|
|
menu.style.display = 'block';
|
|
menuRect = menu.getBoundingClientRect();
|
|
bindMouseMove();
|
|
}
|
|
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 || '');
|
|
// 密码需要在 joinRoom 时存一份到 roomsData
|
|
const pwd = encodeURIComponent(rd.password || '');
|
|
const url = `${location.origin}${location.pathname}?node=${room}&pwd=${pwd}`;
|
|
// 复制到剪贴板
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
addSystemMsg('Share link copied!');
|
|
}, () => {
|
|
prompt('Copy failed, url:', url);
|
|
});
|
|
} else {
|
|
prompt('Copy failed, url:', url);
|
|
}
|
|
}
|
|
closeMenu();
|
|
} else if (e.target.dataset.action === 'exit') {
|
|
// 更彻底的退出/销毁逻辑
|
|
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);
|
|
} else {
|
|
// 没有房间了,刷新页面
|
|
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;
|
|
});
|
|
}
|
|
|
|
// 图片气泡点击预览
|
|
function setupImagePreview() {
|
|
document.getElementById('chat-area').addEventListener('click', function(e) {
|
|
const target = e.target;
|
|
if (target.tagName === 'IMG' && target.closest('.bubble-content')) {
|
|
showImageModal(target.src);
|
|
}
|
|
});
|
|
}
|
|
function showImageModal(src) {
|
|
let modal = document.createElement('div');
|
|
modal.className = 'img-modal-bg';
|
|
modal.innerHTML = `
|
|
<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);
|
|
// 关闭逻辑
|
|
modal.querySelector('.img-modal-close').onclick = () => modal.remove();
|
|
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
|
// 支持缩放和拖动
|
|
const img = modal.querySelector('img');
|
|
let scale = 1;
|
|
let isDragging = false;
|
|
let lastX = 0, lastY = 0;
|
|
let offsetX = 0, offsetY = 0;
|
|
img.ondragstart = function(e) { e.preventDefault(); };
|
|
img.onwheel = 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;
|
|
} else if (prevScale !== scale) {
|
|
// 缩放时保持当前偏移不变
|
|
}
|
|
updateTransform();
|
|
};
|
|
|
|
function updateTransform() {
|
|
img.style.transform = `translate(${offsetX}px,${offsetY}px) scale(${scale})`;
|
|
img.style.cursor = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-in';
|
|
}
|
|
|
|
img.onmousedown = function(ev) {
|
|
if (scale <= 1) return;
|
|
isDragging = true;
|
|
lastX = ev.clientX;
|
|
lastY = ev.clientY;
|
|
img.style.cursor = 'grabbing';
|
|
document.body.style.userSelect = 'none';
|
|
};
|
|
window.onmousemove = function(ev) {
|
|
if (!isDragging) return;
|
|
offsetX += ev.clientX - lastX;
|
|
offsetY += ev.clientY - lastY;
|
|
lastX = ev.clientX;
|
|
lastY = ev.clientY;
|
|
updateTransform();
|
|
};
|
|
window.onmouseup = function() {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
img.style.cursor = 'grab';
|
|
document.body.style.userSelect = '';
|
|
}
|
|
};
|
|
// 双击还原
|
|
img.ondblclick = function() {
|
|
scale = 1;
|
|
offsetX = 0;
|
|
offsetY = 0;
|
|
updateTransform();
|
|
};
|
|
// 关闭时清理事件
|
|
const cleanup = () => {
|
|
window.onmousemove = null;
|
|
window.onmouseup = null;
|
|
document.body.style.userSelect = '';
|
|
};
|
|
modal.addEventListener('remove', cleanup);
|
|
modal.querySelector('.img-modal-close').addEventListener('click', cleanup);
|
|
// 初始化
|
|
updateTransform();
|
|
}
|
|
|
|
// 自动填充房间号和密码(支持分享链接)
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 页面初始化
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const loginContainer = document.getElementById('login-container');
|
|
const chatContainer = document.getElementById('chat-container');
|
|
const loginForm = document.getElementById('login-form');
|
|
if (loginForm) {
|
|
loginForm.addEventListener('submit', loginFormHandler(null));
|
|
}
|
|
// 绑定“进入新房间”按钮
|
|
const joinBtn = document.querySelector('.join-room');
|
|
if (joinBtn) joinBtn.onclick = openLoginModal;
|
|
|
|
// 禁止主登录页输入框输入空格
|
|
preventSpaceInput(document.getElementById('userName'));
|
|
preventSpaceInput(document.getElementById('roomName'));
|
|
preventSpaceInput(document.getElementById('password'));
|
|
|
|
autofillRoomPwd(); // 主登录页
|
|
|
|
setupInputPlaceholder();
|
|
setupMoreBtnMenu();
|
|
setupImagePreview();
|
|
setupEmojiPicker(); // 集成表情选择器
|
|
|
|
initSettings();
|
|
|
|
let settingsBtn = document.getElementById('settings-btn');
|
|
if (settingsBtn) {
|
|
settingsBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
openSettingsPanel();
|
|
};
|
|
}
|
|
|
|
// 点击其它区域关闭设置面板
|
|
document.addEventListener('click', function(ev) {
|
|
const panel = document.getElementById('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();
|
|
}
|
|
}
|
|
});
|
|
|
|
// sidebar 拖拽宽度调整
|
|
const sidebar = document.getElementById('sidebar');
|
|
const resizer = document.getElementById('sidebar-resizer');
|
|
let isResizing = false;
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
if (sidebar && resizer) {
|
|
resizer.addEventListener('mousedown', function(e) {
|
|
isResizing = true;
|
|
startX = e.clientX;
|
|
startWidth = sidebar.offsetWidth;
|
|
document.body.style.cursor = 'ew-resize';
|
|
document.body.style.userSelect = 'none';
|
|
});
|
|
window.addEventListener('mousemove', function(e) {
|
|
if (!isResizing) return;
|
|
let newWidth = startWidth + (e.clientX - startX);
|
|
newWidth = Math.max(200, Math.min(400, newWidth));
|
|
sidebar.style.width = newWidth + 'px';
|
|
});
|
|
window.addEventListener('mouseup', function() {
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// rightbar 拖拽宽度调整
|
|
const rightbar = document.getElementById('rightbar');
|
|
const rightResizer = document.getElementById('rightbar-resizer');
|
|
let isRightResizing = false;
|
|
let rightStartX = 0;
|
|
let rightStartWidth = 0;
|
|
if (rightbar && rightResizer) {
|
|
rightResizer.addEventListener('mousedown', function(e) {
|
|
isRightResizing = true;
|
|
rightStartX = e.clientX;
|
|
rightStartWidth = rightbar.offsetWidth;
|
|
document.body.style.cursor = 'ew-resize';
|
|
document.body.style.userSelect = 'none';
|
|
});
|
|
window.addEventListener('mousemove', function(e) {
|
|
if (!isRightResizing) return;
|
|
let newWidth = rightStartWidth - (e.clientX - rightStartX);
|
|
newWidth = Math.max(200, Math.min(400, newWidth));
|
|
rightbar.style.width = newWidth + 'px';
|
|
});
|
|
window.addEventListener('mouseup', function() {
|
|
if (isRightResizing) {
|
|
isRightResizing = false;
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// 自动聚焦到输入框
|
|
const input = document.querySelector('.input-message-input');
|
|
if (input) {
|
|
input.focus();
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
const text = input.innerText.trim();
|
|
// 只用当前房间的 chat 实例
|
|
if (text && roomsData[activeRoomIndex]?.chat) {
|
|
roomsData[activeRoomIndex].chat.sendChannelMessage('text', text);
|
|
addMsg(text);
|
|
input.innerText = '';
|
|
input.dispatchEvent(new Event('input'));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 附件按钮和图片发送、粘贴图片功能统一交给 util.image.js
|
|
setupImageSend({
|
|
inputSelector: '.input-message-input',
|
|
attachBtnSelector: '.chat-attach-btn',
|
|
fileInputSelector: '.new-message-wrapper input[type="file"]',
|
|
onSend: (dataUrl) => {
|
|
if (roomsData[activeRoomIndex]?.chat) {
|
|
roomsData[activeRoomIndex].chat.sendChannelMessage('image', dataUrl);
|
|
addMsg(dataUrl, false, 'image');
|
|
}
|
|
}
|
|
});
|
|
|
|
// 初始化
|
|
renderRooms(activeRoomIndex);
|
|
renderMainHeader();
|
|
renderUserList();
|
|
setupTabs();
|
|
renderChatArea();
|
|
}); |