// 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'; import { closeSettingsPanel } from './util.settings.js'; import { t } from './util.i18n.js'; import { updateChatInputStyle } from './chat.js'; // Utility functions for security and error handling // 安全和错误处理工具函数 // Simple encryption/decryption using base64 and character shifting // 使用base64和字符偏移的简单加密/解密 function simpleEncrypt(text) { if (!text) return ''; // Convert to base64 and shift characters const base64 = btoa(unescape(encodeURIComponent(text))); return base64.split('').map(char => { const code = char.charCodeAt(0); return String.fromCharCode(code + 3); }).join(''); } function simpleDecrypt(encrypted) { if (!encrypted) return ''; try { // Reverse character shifting and decode base64 const shifted = encrypted.split('').map(char => { const code = char.charCodeAt(0); return String.fromCharCode(code - 3); }).join(''); return decodeURIComponent(escape(atob(shifted))); } catch (error) { console.warn('Failed to decrypt data:', error); return ''; } } // Validate room data // 验证房间数据 function validateRoomData(roomData) { if (!roomData) { return { valid: false, error: 'No room data available' }; } if (!roomData.roomName || roomData.roomName.trim() === '') { return { valid: false, error: 'Room name is required' }; } return { valid: true }; } // Copy text to clipboard with fallback // 复制文本到剪贴板(含降级处理) function copyToClipboard(text, successMessage = t('action.copied', 'Copied to clipboard!'), errorPrefix = t('action.copy_failed', 'Copy failed, url:')) { if (!text) { window.addSystemMsg && window.addSystemMsg(t('action.nothing_to_copy', 'Nothing to copy')); return; } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => { window.addSystemMsg && window.addSystemMsg(successMessage); }).catch((error) => { console.error('Clipboard write failed:', error); showFallbackCopy(text, errorPrefix); }); } else { showFallbackCopy(text, errorPrefix); } } // Show fallback copy method // 显示降级复制方法 function showFallbackCopy(text, prefix) { if (typeof prompt === 'function') { prompt(prefix, text); } else { // For environments where prompt is not available window.addSystemMsg && window.addSystemMsg(t('action.copy_not_supported', 'Copy not supported in this environment')); } } // Execute menu action with error handling // 执行菜单操作并处理错误 function executeMenuAction(action, closeMenuCallback) { try { switch (action) { case 'share': handleShareAction(); break; case 'exit': handleExitAction(); break; default: console.warn('Unknown menu action:', action); } } catch (error) { console.error('Menu action failed:', error); window.addSystemMsg && window.addSystemMsg(t('action.action_failed', 'Action failed. Please try again.')); } finally { closeMenuCallback && closeMenuCallback(); } } // Handle share action // 处理分享操作 function handleShareAction() { const validation = validateRoomData(roomsData[activeRoomIndex]); if (!validation.valid) { window.addSystemMsg && window.addSystemMsg(`${t('action.cannot_share', 'Cannot share:')} ${validation.error}`); return; } const rd = roomsData[activeRoomIndex]; const roomName = rd.roomName.trim(); const password = rd.password || ''; // Encrypt room name and password const encryptedRoom = simpleEncrypt(roomName); const encryptedPwd = password ? simpleEncrypt(password) : ''; // Create share URL with encrypted data let url = `${location.origin}${location.pathname}?r=${encodeURIComponent(encryptedRoom)}`; if (encryptedPwd) { url += `&p=${encodeURIComponent(encryptedPwd)}`; } copyToClipboard(url, t('action.share_copied', 'Share link copied!'), t('action.copy_url_failed', 'Copy failed, url:')); } // Handle exit action // 处理退出操作 function handleExitAction() { try { const result = exitRoom(); if (!result) { location.reload(); } } catch (error) { console.error('Exit room failed:', error); // Force reload as fallback location.reload(); } } // 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 = `
#${safeRoomName}
${onlineCount} ${t('ui.members', 'members')}
${t('action.share', 'Share')}
${t('action.exit', 'Quit')}
`; setupMoreBtnMenu(); setupMobileUIHandlers() } // Setup mobile UI event handlers // 设置移动端 UI 事件处理 export function setupMobileUIHandlers() { const sidebar = document.getElementById('sidebar'); const rightbar = document.getElementById('rightbar'); const settingsSidebar = document.getElementById('settings-sidebar'); 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() { // Check if settings sidebar is open if (settingsSidebar && settingsSidebar.classList.contains('mobile-open')) { closeSettingsPanel(); } else { 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') } } // Consolidated click event listener for closing sidebars document.addEventListener('click', function(ev) { const settingsBtn = $id('settings-btn'); const isSettingsButtonClick = settingsBtn && settingsBtn.contains(ev.target); const isSettingsBackButtonClick = $id('settings-back-btn') && $id('settings-back-btn').contains(ev.target); // Close settings sidebar if open and click is outside (and not on the open button or back button) if (settingsSidebar && (settingsSidebar.classList.contains('open') || settingsSidebar.classList.contains('mobile-open'))) { if (!settingsSidebar.contains(ev.target) && !isSettingsButtonClick && !isSettingsBackButtonClick) { closeSettingsPanel(); } } if (isMobile()) { // Mobile-specific logic 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 (settingsSidebar && settingsSidebar.classList.contains('mobile-open')) { // 检查点击目标是否为设置按钮本身 const isSettingsButton = settingsBtn && settingsBtn.contains(ev.target); if (!settingsSidebar.contains(ev.target) && !isSettingsButton) { closeSettingsPanel(); } } 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'); } } } else { // Desktop-specific logic // 如果设置侧边栏打开,并且点击位置在侧边栏外部且不是设置按钮本身 if (settingsSidebar && settingsSidebar.classList.contains('open')) { const isSettingsButton = settingsBtn && settingsBtn.contains(ev.target); if (!settingsSidebar.contains(ev.target) && !isSettingsButton) { closeSettingsPanel(); } } } }) } // 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 (others.length > 0) { const tip = document.createElement('div'); tip.className = 'member-tip member-tip-center'; tip.textContent = t('ui.start_private_chat', '选择用户开始私信'); userListEl.appendChild(tip); } 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 = `
${safeUserName}${isMe?t('ui.me', ' (me)'):''}
`; const avatarEl = div.querySelector('.avatar'); if (avatarEl) { const svg = createAvatarSVG(rawName); const cleanSvg = svg.replace(/)<[^<]*)*<\/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 animating = false; // Open the menu // 打开菜单 function openMenu() { menu.style.display = 'block'; menu.classList.remove('close'); // 强制触发重绘,然后添加打开动画 menu.offsetHeight; // 强制重绘 menu.classList.add('open'); } // 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; }, 300); } 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')) { const action = e.target.dataset.action; executeMenuAction(action, 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 = t('ui.node_exists', 'Node already exists'); roomInput.parentNode.appendChild(warnTip); roomInput._warnTip = warnTip; roomInput.focus() } if (btn) { btn.disabled = false; btn.innerText = t('ui.enter', 'ENTER') } return } if (btn) { btn.disabled = true; btn.innerText = t('ui.connecting', 'Connecting...') } window.joinRoom(userName, roomName, password, modal, function(success) { if (!success && btn) { btn.disabled = false; btn.innerText = 'ENTER' } }) } } // 生成登录表单HTML // Generate login form HTML export function generateLoginForm(isModal = false) { const idPrefix = isModal ? '-modal' : ''; return `
`; } export function openLoginModal() { const modal = document.createElement('div'); modal.className = 'login-modal'; modal.innerHTML = `

${t('ui.enter_node', 'Enter a Node')}

${generateLoginForm(true)}
`; 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('-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); // Check for new encrypted format first const encryptedRoom = params.get('r'); const encryptedPwd = params.get('p'); // Check for old plaintext format (for backward compatibility) const plaintextRoom = params.get('node'); const plaintextPwd = params.get('pwd'); let roomValue = ''; let pwdValue = ''; let isPlaintext = false; if (encryptedRoom) { // New encrypted format roomValue = simpleDecrypt(decodeURIComponent(encryptedRoom)); if (encryptedPwd) { pwdValue = simpleDecrypt(decodeURIComponent(encryptedPwd)); } } else if (plaintextRoom) { // Old plaintext format - show security warning roomValue = decodeURIComponent(plaintextRoom); if (plaintextPwd) { pwdValue = decodeURIComponent(plaintextPwd); } isPlaintext = true; // Show security warning for plaintext URLs if (window.addSystemMsg) { window.addSystemMsg(t('system.security_warning', '⚠️ This link uses an old format. Room data is not encrypted.'), true); } } // Fill in the form fields if (roomValue) { const roomInput = document.getElementById(formPrefix + 'roomName'); if (roomInput) { roomInput.value = roomValue; roomInput.readOnly = true; roomInput.style.background = isPlaintext ? '#fff9e6' : '#f5f5f5'; // Yellow tint for plaintext } // Always lock password field when coming from a share link const pwdInput = document.getElementById(formPrefix + 'password'); if (pwdInput) { pwdInput.value = pwdValue; // Will be empty string if no password pwdInput.readOnly = true; pwdInput.style.background = isPlaintext ? '#fff9e6' : '#f5f5f5'; // Yellow tint for plaintext // Add visual indicator for no password and keep label floating if (!pwdValue) { pwdInput.placeholder = 'No password required'; // Add a space to make the input appear "filled" so the label stays floating pwdInput.value = ' '; // Make the text invisible but keep the label floating behavior pwdInput.style.color = 'transparent'; } } } // Clear URL parameters for security if (roomValue || pwdValue) { window.history.replaceState({}, '', location.pathname); } } // 初始化登录表单 // Initialize login form export function initLoginForm() { const loginFormContainer = document.getElementById('login-form'); if (loginFormContainer && loginFormContainer.children.length === 0) { // 只有当登录表单为空时才初始化 // Only initialize if login form is empty loginFormContainer.innerHTML = generateLoginForm(false); } // 为登录页面添加class,用于手机适配 // Add class to login page for mobile adaptation document.body.classList.add('login-page'); } // Listen for language change events to refresh UI // 监听语言变更事件刷新UI window.addEventListener('languageChange', () => { // Refresh main header and user list renderMainHeader(); renderUserList(false); // Refresh chat input placeholder updateChatInputStyle(); }); // Listen for regenerate login form event // 监听重新生成登录表单事件 window.addEventListener('regenerateLoginForm', () => { const loginFormContainer = document.getElementById('login-form'); if (loginFormContainer) { loginFormContainer.innerHTML = generateLoginForm(false); } }); // 初始化翻转卡片功能 // Initialize flip card functionality export function initFlipCard() { const flipCard = document.getElementById('flip-card'); const helpBtn = document.getElementById('help-btn'); const backBtn = document.getElementById('back-btn'); if (!flipCard || !helpBtn || !backBtn) return; const flipCardInner = flipCard.querySelector('.flip-card-inner'); if (!flipCardInner) return; // 翻转状态 let isFlipped = false; // 简单的翻转函数 function toggleFlip() { isFlipped = !isFlipped; if (isFlipped) { flipCardInner.classList.add('flipped'); } else { flipCardInner.classList.remove('flipped'); } } // 帮助按钮点击事件 helpBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFlip(); }); // 返回按钮点击事件 backBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleFlip(); }); }