// static/script.js (function() { 'use strict'; // ============================================= // State // ============================================= const State = { token: localStorage.getItem('token'), user: null, ws: null, wsState: 'disconnected', messages: new Map(), messageOrder: [], cursor: null, hasMore: true, replyTo: null, pendingFile: null, typingUsers: new Map(), onlineUsers: new Map(), isAtBottom: true, theme: localStorage.getItem('theme') || 'light', customColors: { self: localStorage.getItem('custom-self') || '#2563EB', other: localStorage.getItem('custom-other') || '#F3F4F6', text: localStorage.getItem('custom-text') || '#111827' } }; // ============================================= // DOM Cache // ============================================= const DOM = { // Screens authScreen: document.getElementById('auth-screen'), chatScreen: document.getElementById('chat-screen'), // Auth loginForm: document.getElementById('login-form'), signupForm: document.getElementById('signup-form'), authTabs: document.querySelectorAll('.auth-tab'), authError: document.getElementById('auth-error'), authErrorText: document.getElementById('auth-error-text'), authLoading: document.getElementById('auth-loading'), // Header headerDisplayName: document.getElementById('header-display-name'), headerAvatar: document.getElementById('header-avatar'), headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'), headerStatus: document.getElementById('header-status'), onlineBadge: document.getElementById('online-badge'), // Connection connectionStatus: document.getElementById('connection-status'), connectionText: document.getElementById('connection-text'), // Messages messagesList: document.getElementById('messages-list'), messagesScroll: document.getElementById('messages-scroll'), emptyState: document.getElementById('empty-state'), loadOlderBtn: document.getElementById('load-older-btn'), scrollBottomBtn: document.getElementById('scroll-bottom-btn'), // Typing typingIndicator: document.getElementById('typing-indicator'), typingText: document.getElementById('typing-text'), // Reply replyPreview: document.getElementById('reply-preview'), replyUser: document.getElementById('reply-user'), replyText: document.getElementById('reply-text'), // Compose composeForm: document.getElementById('compose-form'), messageInput: document.getElementById('message-input'), sendBtn: document.getElementById('send-btn'), attachBtn: document.getElementById('attach-btn'), emojiBtn: document.getElementById('emoji-btn'), fileInput: document.getElementById('file-input'), // Upload uploadPreview: document.getElementById('upload-preview'), uploadFilename: document.getElementById('upload-filename'), cancelUpload: document.getElementById('cancel-upload'), uploadProgress: document.getElementById('upload-progress'), progressFill: document.getElementById('progress-fill'), progressText: document.getElementById('progress-text'), // Modals settingsModal: document.getElementById('settings-modal'), onlineModal: document.getElementById('online-modal'), emojiPicker: document.getElementById('emoji-picker'), emojiGrid: document.getElementById('emoji-grid'), // Settings settingsDisplayName: document.getElementById('settings-display-name'), settingsUsername: document.getElementById('settings-username'), settingsAvatarPreview: document.getElementById('settings-avatar-preview'), settingsAvatarImg: document.getElementById('settings-avatar-img'), avatarInput: document.getElementById('avatar-input'), avatarUploadBtn: document.getElementById('avatar-upload-btn'), themeOptions: document.querySelectorAll('.theme-option'), customThemeSettings: document.getElementById('custom-theme-settings'), customSelfColor: document.getElementById('custom-self-color'), customOtherColor: document.getElementById('custom-other-color'), customTextColor: document.getElementById('custom-text-color'), selfColorHex: document.getElementById('self-color-hex'), otherColorHex: document.getElementById('other-color-hex'), textColorHex: document.getElementById('text-color-hex'), // Online users modal onlineBtn: document.getElementById('online-btn'), onlineUsersList: document.getElementById('online-users-list'), onlineTotal: document.getElementById('online-total'), // Toast toastContainer: document.getElementById('toast-container') }; const API = window.location.origin; // ============================================= // Theme // ============================================= function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); State.theme = theme; localStorage.setItem('theme', theme); if (theme === 'custom') { const selfColor = State.customColors.self; const otherColor = State.customColors.other; const textColor = State.customColors.text; document.documentElement.style.setProperty('--bubble-self', selfColor); document.documentElement.style.setProperty('--bubble-other', otherColor); document.documentElement.style.setProperty('--bubble-other-text', textColor); const isLight = (c) => { const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16); return (0.299 * r + 0.587 * g + 0.114 * b) > 150; }; document.documentElement.style.setProperty('--bubble-self-text', isLight(selfColor) ? '#111827' : '#FFFFFF'); } else { // Remove inline custom properties to let CSS variables take over document.documentElement.style.removeProperty('--bubble-self'); document.documentElement.style.removeProperty('--bubble-other'); document.documentElement.style.removeProperty('--bubble-other-text'); document.documentElement.style.removeProperty('--bubble-self-text'); } } applyTheme(State.theme); // ============================================= // Toast Notifications // ============================================= function showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; let icon = 'info-circle'; if (type === 'success') icon = 'check-circle'; else if (type === 'error') icon = 'exclamation-circle'; else if (type === 'warning') icon = 'exclamation-triangle'; toast.innerHTML = `${message}`; DOM.toastContainer.appendChild(toast); setTimeout(() => toast.remove(), 4000); } // ============================================= // Authentication // ============================================= DOM.authTabs.forEach(tab => { tab.addEventListener('click', () => { const target = tab.dataset.tab; DOM.authTabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); DOM.loginForm.classList.toggle('active', target === 'login'); DOM.signupForm.classList.toggle('active', target === 'signup'); DOM.authError.style.display = 'none'; }); }); DOM.loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; await login(username, password); }); DOM.signupForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('signup-username').value.trim(); const display = document.getElementById('signup-display').value.trim() || username; const password = document.getElementById('signup-password').value; await signup(username, password, display); }); async function login(username, password) { DOM.authLoading.classList.remove('hidden'); hideAuthError(); try { const res = await fetch(`${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Login failed'); handleAuthSuccess(data); } catch (err) { showAuthError(err.message); } finally { DOM.authLoading.classList.add('hidden'); } } async function signup(username, password, display) { DOM.authLoading.classList.remove('hidden'); hideAuthError(); try { const res = await fetch(`${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Signup failed'); handleAuthSuccess(data); } catch (err) { showAuthError(err.message); } finally { DOM.authLoading.classList.add('hidden'); } } function handleAuthSuccess(data) { State.token = data.token; State.user = data.user; localStorage.setItem('token', State.token); showChat(); connectWebSocket(); } function showAuthError(msg) { DOM.authErrorText.textContent = msg; DOM.authError.style.display = 'flex'; } function hideAuthError() { DOM.authError.style.display = 'none'; } // ============================================= // WebSocket // ============================================= function connectWebSocket() { if (!State.token) return; if (State.ws && State.ws.readyState === WebSocket.OPEN) return; State.wsState = 'connecting'; updateConnectionUI(); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`; State.ws = new WebSocket(url); State.ws.onopen = () => { State.wsState = 'connected'; updateConnectionUI(); loadMessages(); requestOnlineUsers(); }; State.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); handleWSMessage(msg); } catch (e) { console.error('WS parse error', e); } }; State.ws.onclose = () => { if (State.wsState === 'connected') { State.wsState = 'reconnecting'; updateConnectionUI(); setTimeout(connectWebSocket, 3000); } else { State.wsState = 'disconnected'; updateConnectionUI(); } }; State.ws.onerror = () => { State.wsState = 'disconnected'; updateConnectionUI(); }; } function updateConnectionUI() { const status = DOM.connectionStatus; status.className = 'connection-status'; switch (State.wsState) { case 'connected': status.classList.add('connected'); DOM.connectionText.textContent = 'Connected'; break; case 'reconnecting': status.classList.add('reconnecting'); DOM.connectionText.textContent = 'Reconnecting...'; break; default: status.classList.add('disconnected'); DOM.connectionText.textContent = 'Disconnected'; } } function handleWSMessage(msg) { switch (msg.type) { case 'connection_established': State.user = { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path }; msg.online_users.forEach(u => State.onlineUsers.set(u.id, u)); updateHeader(); updateOnlineBadge(); break; case 'new_message': if (State.messages.has(msg.message.id)) return; State.messages.set(msg.message.id, msg.message); State.messageOrder.push(msg.message.id); renderMessage(msg.message); if (State.isAtBottom) scrollToBottom(); if (document.visibilityState === 'visible') sendMarkRead(msg.message.id); if (document.visibilityState !== 'visible' && Notification.permission === 'granted') { new Notification(`${msg.message.sender_display_name}`, { body: msg.message.content || 'Sent a file', icon: '/static/icon.png' }); } break; case 'messages_loaded': msg.messages.forEach(m => { if (!State.messages.has(m.id)) { State.messages.set(m.id, m); State.messageOrder.unshift(m.id); } }); State.hasMore = msg.has_more; if (msg.next_cursor) State.cursor = msg.next_cursor; renderAllMessages(); DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more); break; case 'user_status': if (msg.status === 'online') { State.onlineUsers.set(msg.user_id, { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path }); } else { State.onlineUsers.delete(msg.user_id); } updateOnlineBadge(); break; case 'typing_indicator': if (msg.is_typing) { State.typingUsers.set(msg.user_id, msg); } else { State.typingUsers.delete(msg.user_id); } updateTypingIndicator(); break; case 'message_edited': if (State.messages.has(msg.message_id)) { const msgObj = State.messages.get(msg.message_id); msgObj.content = msg.content; msgObj.is_edited = true; updateMessageBubble(msg.message_id); } break; case 'message_deleted': if (State.messages.has(msg.message_id)) { const msgObj = State.messages.get(msg.message_id); msgObj.is_deleted = true; updateMessageBubble(msg.message_id); } break; case 'message_read': if (State.messages.has(msg.message_id) && State.messages.get(msg.message_id).sender_id === State.user.id) { State.messages.get(msg.message_id).status = 'read'; updateMessageBubble(msg.message_id); } break; case 'online_users': State.onlineUsers.clear(); msg.users.forEach(u => State.onlineUsers.set(u.id, u)); updateOnlineBadge(); if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers(); break; case 'error': showToast(msg.message, 'error'); } } // ============================================= // Message Actions // ============================================= function loadMessages() { if (State.ws?.readyState === WebSocket.OPEN) { State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 })); } } function sendMarkRead(messageId) { if (State.ws?.readyState === WebSocket.OPEN) { State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId })); } } // ============================================= // Rendering // ============================================= function renderMessage(msg) { if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return; const wrapper = document.createElement('div'); wrapper.className = `message-wrapper ${msg.sender_id === State.user.id ? 'self' : 'other'}`; wrapper.dataset.id = msg.id; wrapper.innerHTML = buildMessageHTML(msg); DOM.messagesList.appendChild(wrapper); } function buildMessageHTML(msg) { if (msg.is_deleted) return '
This message was deleted
'; const isSelf = msg.sender_id === State.user?.id; let html = ''; // reply indicator if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) { const reply = State.messages.get(msg.reply_to_id); html += `
${reply.sender_username}: ${truncate(reply.content, 50)}
`; } html += `
`; html += `
${escapeHtml(msg.content)}
`; if (msg.file_path) html += buildAttachmentHTML(msg); html += `
`; html += `${formatTime(msg.timestamp_ms)}`; if (msg.is_edited) html += ' edited'; if (isSelf) { const icon = msg.status === 'read' ? '' : ''; html += `${icon}`; } html += `
`; html += ``; if (isSelf) { html += ``; html += ``; } html += `
`; return html; } function buildAttachmentHTML(msg) { const url = `${API}/api/download/${encodeURIComponent(msg.file_path)}?token=${encodeURIComponent(State.token)}`; if (msg.file_type && msg.file_type.startsWith('image/')) { return `
${msg.file_name}
`; } else if (msg.file_type && msg.file_type.startsWith('video/')) { return `
`; } else if (msg.file_type && msg.file_type.startsWith('audio/')) { return `
`; } else { return `
${msg.file_name || 'File'}
${formatFileSize(msg.file_size)}
`; } } function updateMessageBubble(id) { const el = DOM.messagesList.querySelector(`[data-id="${id}"]`); if (!el) return; const msg = State.messages.get(id); if (msg) el.innerHTML = buildMessageHTML(msg); } function renderAllMessages() { DOM.messagesList.innerHTML = ''; if (State.messageOrder.length === 0) { DOM.emptyState.style.display = 'flex'; } else { DOM.emptyState.style.display = 'none'; State.messageOrder.forEach(id => { const msg = State.messages.get(id); if (msg) renderMessage(msg); }); } if (State.isAtBottom) scrollToBottom(); } function scrollToBottom() { DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight; } // ============================================= // Scroll detection // ============================================= DOM.messagesScroll.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll; State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 60; DOM.scrollBottomBtn.classList.toggle('hidden', State.isAtBottom); }); DOM.scrollBottomBtn.addEventListener('click', scrollToBottom); // ============================================= // Compose // ============================================= DOM.composeForm.addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); }); DOM.messageInput.addEventListener('input', () => { const hasContent = DOM.messageInput.value.trim().length > 0 || !!State.pendingFile; DOM.sendBtn.disabled = !hasContent; if (hasContent) sendTyping(true); autoResize(); }); function sendMessage() { const content = DOM.messageInput.value.trim(); if (!content && !State.pendingFile) return; if (!State.ws || State.ws.readyState !== WebSocket.OPEN) { showToast('Not connected', 'warning'); return; } const payload = { type: 'send_message', content: content, reply_to_id: State.replyTo ? State.replyTo.id : null, client_id: generateId() }; if (State.pendingFile) { payload.file_path = State.pendingFile.file_path; payload.file_type = State.pendingFile.file_type; payload.file_name = State.pendingFile.file_name; payload.file_size = State.pendingFile.file_size; } State.ws.send(JSON.stringify(payload)); DOM.messageInput.value = ''; clearReply(); clearPendingFile(); autoResize(); sendTyping(false); } function sendTyping(isTyping) { if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return; clearTimeout(window.__typingTimer); if (isTyping) { State.ws.send(JSON.stringify({ type: 'typing', is_typing: true })); window.__typingTimer = setTimeout(() => sendTyping(false), 2000); } else { State.ws.send(JSON.stringify({ type: 'typing', is_typing: false })); } } function updateTypingIndicator() { const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user.id); DOM.typingIndicator.classList.toggle('hidden', users.length === 0); if (users.length > 0) { DOM.typingText.textContent = users.map(u => u.display_name).join(', ') + ' typing...'; } } // ============================================= // Reply // ============================================= function replyTo(messageId) { const msg = State.messages.get(messageId); if (!msg) return; State.replyTo = msg; DOM.replyUser.textContent = msg.sender_username; DOM.replyText.textContent = truncate(msg.content, 50); DOM.replyPreview.classList.remove('hidden'); DOM.messageInput.focus(); } function clearReply() { State.replyTo = null; DOM.replyPreview.classList.add('hidden'); } document.addEventListener('click', (e) => { if (e.target.id === 'cancel-reply' || e.target.closest('#cancel-reply')) { clearReply(); } }); // ============================================= // Edit / Delete // ============================================= function editMessage(messageId) { const msg = State.messages.get(messageId); if (!msg) return; const newContent = prompt('Edit message:', msg.content); if (newContent !== null && newContent.trim() !== '') { if (State.ws?.readyState === WebSocket.OPEN) { State.ws.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() })); } } } function deleteMessage(messageId) { if (confirm('Permanently delete this message?')) { if (State.ws?.readyState === WebSocket.OPEN) { State.ws.send(JSON.stringify({ type: 'delete_message', message_id: messageId })); } } } // ============================================= // File Upload (Chunked) // ============================================= DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click()); DOM.fileInput.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; await uploadChunked(file); e.target.value = ''; }); async function uploadChunked(file) { const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const uploadId = generateId(); DOM.uploadProgress.classList.remove('hidden'); updateProgress(0, file.name); for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('file', blob); const url = `${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${totalChunks}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}&file_size=${file.size}&upload_id=${uploadId}`; try { const res = await fetch(url, { method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token } }); if (!res.ok) throw new Error('Upload failed'); const data = await res.json(); updateProgress(((i + 1) / totalChunks) * 100, file.name); if (data.status === 'complete') { State.pendingFile = { file_path: data.file_path, file_type: data.file_type, file_name: data.file_name, file_size: data.file_size }; showUploadPreview(data.file_name); DOM.uploadProgress.classList.add('hidden'); return; } } catch (err) { showToast('Upload failed', 'error'); DOM.uploadProgress.classList.add('hidden'); break; } } DOM.uploadProgress.classList.add('hidden'); } function updateProgress(percent, filename) { DOM.progressFill.style.width = `${percent}%`; DOM.progressText.textContent = `${Math.round(percent)}%`; if (filename) DOM.uploadFilename.textContent = filename; } function showUploadPreview(filename) { DOM.uploadPreview.classList.remove('hidden'); DOM.uploadFilename.textContent = filename; DOM.sendBtn.disabled = false; } function clearPendingFile() { State.pendingFile = null; DOM.uploadPreview.classList.add('hidden'); } DOM.cancelUpload.addEventListener('click', clearPendingFile); // ============================================= // Emoji Picker (basic) // ============================================= DOM.emojiBtn.addEventListener('click', (e) => { e.stopPropagation(); DOM.emojiPicker.classList.toggle('hidden'); if (!DOM.emojiPicker.classList.contains('hidden')) { renderEmojis('smileys'); } }); document.addEventListener('click', (e) => { if (!DOM.emojiPicker.contains(e.target) && e.target !== DOM.emojiBtn) { DOM.emojiPicker.classList.add('hidden'); } }); const emojiSets = { smileys: ['๐Ÿ˜€','๐Ÿ˜ƒ','๐Ÿ˜„','๐Ÿ˜','๐Ÿ˜…','๐Ÿ˜‚','๐Ÿคฃ','๐Ÿ˜Š','๐Ÿ˜‡','๐Ÿ™‚','๐Ÿ˜‰','๐Ÿ˜Œ','๐Ÿ˜','๐Ÿฅฐ','๐Ÿ˜˜','๐Ÿ˜—','๐Ÿ˜‹','๐Ÿ˜›','๐Ÿ˜œ','๐Ÿคช','๐Ÿ˜','๐Ÿค‘','๐Ÿค—','๐Ÿคญ','๐Ÿคซ','๐Ÿค”','๐Ÿค','๐Ÿคจ','๐Ÿ˜','๐Ÿ˜‘','๐Ÿ˜ถ','๐Ÿ˜','๐Ÿ˜’','๐Ÿ™„','๐Ÿ˜ฌ','๐Ÿ˜ฎ','๐Ÿคฅ','๐Ÿ˜Œ','๐Ÿ˜”','๐Ÿ˜ช','๐Ÿคค','๐Ÿ˜ด','๐Ÿ˜ท','๐Ÿค’','๐Ÿค•','๐Ÿคข','๐Ÿคฎ','๐Ÿคง','๐Ÿ˜ต','๐Ÿคฏ','๐Ÿฅต','๐Ÿฅถ','๐Ÿ˜Ž','๐Ÿค“','๐Ÿง','๐Ÿ˜•','๐Ÿ˜Ÿ','๐Ÿ™','๐Ÿ˜ฎ','๐Ÿ˜ฏ','๐Ÿ˜ฒ','๐Ÿ˜ณ','๐Ÿฅบ','๐Ÿ˜ฆ','๐Ÿ˜ง','๐Ÿ˜จ','๐Ÿ˜ฐ','๐Ÿ˜ฅ','๐Ÿ˜ข','๐Ÿ˜ญ','๐Ÿ˜ฑ','๐Ÿ˜–','๐Ÿ˜ฃ','๐Ÿ˜ž','๐Ÿ˜“','๐Ÿ˜ฉ','๐Ÿ˜ซ','๐Ÿฅฑ','๐Ÿ˜ค','๐Ÿ˜ก','๐Ÿ˜ ','๐Ÿคฌ','๐Ÿ˜ˆ','๐Ÿ‘ฟ','๐Ÿ’€','โ˜ ๏ธ','๐Ÿ’ฉ','๐Ÿคก','๐Ÿ‘น','๐Ÿ‘บ','๐Ÿ‘ป','๐Ÿ‘ฝ','๐Ÿ‘พ','๐Ÿค–'], people: ['๐Ÿ‘ถ','๐Ÿง’','๐Ÿ‘ฆ','๐Ÿ‘ง','๐Ÿง‘','๐Ÿ‘ฑ','๐Ÿ‘จ','๐Ÿง”','๐Ÿ‘ฉ','๐Ÿง“','๐Ÿ‘ด','๐Ÿ‘ต','๐Ÿ‘ฒ','๐Ÿ‘ณ','๐Ÿง•','๐Ÿ‘ฎ','๐Ÿ‘ท','๐Ÿ’‚','๐Ÿ•ต๏ธ','๐Ÿง‘โ€โš•๏ธ','๐Ÿ‘จโ€โš•๏ธ','๐Ÿ‘ฉโ€โš•๏ธ','๐Ÿง‘โ€๐ŸŽ“','๐Ÿ‘จโ€๐ŸŽ“','๐Ÿ‘ฉโ€๐ŸŽ“','๐Ÿง‘โ€๐Ÿซ','๐Ÿ‘จโ€๐Ÿซ','๐Ÿ‘ฉโ€๐Ÿซ','๐Ÿง‘โ€โš–๏ธ','๐Ÿ‘จโ€โš–๏ธ','๐Ÿ‘ฉโ€โš–๏ธ','๐Ÿง‘โ€๐ŸŒพ','๐Ÿ‘จโ€๐ŸŒพ','๐Ÿ‘ฉโ€๐ŸŒพ','๐Ÿง‘โ€๐Ÿณ','๐Ÿ‘จโ€๐Ÿณ','๐Ÿ‘ฉโ€๐Ÿณ','๐Ÿง‘โ€๐Ÿ”ง','๐Ÿ‘จโ€๐Ÿ”ง','๐Ÿ‘ฉโ€๐Ÿ”ง','๐Ÿง‘โ€๐Ÿญ','๐Ÿ‘จโ€๐Ÿญ','๐Ÿ‘ฉโ€๐Ÿญ','๐Ÿง‘โ€๐Ÿ’ผ','๐Ÿ‘จโ€๐Ÿ’ผ','๐Ÿ‘ฉโ€๐Ÿ’ผ','๐Ÿง‘โ€๐Ÿ”ฌ','๐Ÿ‘จโ€๐Ÿ”ฌ','๐Ÿ‘ฉโ€๐Ÿ”ฌ','๐Ÿง‘โ€๐Ÿ’ป','๐Ÿ‘จโ€๐Ÿ’ป','๐Ÿ‘ฉโ€๐Ÿ’ป','๐Ÿง‘โ€๐ŸŽค','๐Ÿ‘จโ€๐ŸŽค','๐Ÿ‘ฉโ€๐ŸŽค','๐Ÿง‘โ€๐ŸŽจ','๐Ÿ‘จโ€๐ŸŽจ','๐Ÿ‘ฉโ€๐ŸŽจ','๐Ÿง‘โ€โœˆ๏ธ','๐Ÿ‘จโ€โœˆ๏ธ','๐Ÿ‘ฉโ€โœˆ๏ธ','๐Ÿง‘โ€๐Ÿš€','๐Ÿ‘จโ€๐Ÿš€','๐Ÿ‘ฉโ€๐Ÿš€','๐Ÿง‘โ€๐Ÿš’','๐Ÿ‘จโ€๐Ÿš’','๐Ÿ‘ฉโ€๐Ÿš’','๐Ÿ‘ฎโ€โ™€๏ธ','๐Ÿ‘ฎโ€โ™‚๏ธ','๐Ÿ•ต๏ธโ€โ™€๏ธ','๐Ÿ•ต๏ธโ€โ™‚๏ธ','๐Ÿ’‚โ€โ™€๏ธ','๐Ÿ’‚โ€โ™‚๏ธ','๐Ÿ‘ทโ€โ™€๏ธ','๐Ÿ‘ทโ€โ™‚๏ธ'], animals: ['๐Ÿถ','๐Ÿฑ','๐Ÿญ','๐Ÿน','๐Ÿฐ','๐ŸฆŠ','๐Ÿป','๐Ÿผ','๐Ÿปโ€โ„๏ธ','๐Ÿจ','๐Ÿฏ','๐Ÿฆ','๐Ÿฎ','๐Ÿท','๐Ÿธ','๐Ÿต','๐Ÿ™ˆ','๐Ÿ™‰','๐Ÿ™Š','๐Ÿ’','๐Ÿ”','๐Ÿง','๐Ÿฆ','๐Ÿค','๐Ÿฃ','๐Ÿฅ','๐Ÿฆ†','๐Ÿฆ…','๐Ÿฆ‰','๐Ÿฆ‡','๐Ÿบ','๐Ÿ—','๐Ÿด','๐Ÿฆ„','๐Ÿ','๐Ÿชฑ','๐Ÿ›','๐Ÿฆ‹','๐ŸŒ','๐Ÿž','๐Ÿœ','๐Ÿชฐ','๐Ÿชฒ','๐Ÿชณ','๐ŸฆŸ','๐Ÿฆ—','๐Ÿ•ท๏ธ','๐Ÿฆ‚','๐Ÿข','๐Ÿ','๐ŸฆŽ','๐Ÿฆ–','๐Ÿฆ•','๐Ÿ™','๐Ÿฆ‘','๐Ÿฆ','๐Ÿฆž','๐Ÿฆ€','๐Ÿก','๐Ÿ ','๐ŸŸ','๐Ÿฌ','๐Ÿณ','๐Ÿ‹','๐Ÿฆˆ','๐ŸŠ','๐Ÿ…','๐Ÿ†','๐Ÿฆ“','๐Ÿฆ','๐Ÿฆง','๐Ÿ˜','๐Ÿฆ›','๐Ÿฆ','๐Ÿช','๐Ÿซ','๐Ÿฆ’','๐Ÿฆ˜','๐Ÿƒ','๐Ÿ‚','๐Ÿ„','๐ŸŽ','๐Ÿ–','๐Ÿ','๐Ÿ‘','๐Ÿฆ™','๐Ÿ','๐ŸฆŒ','๐Ÿ•','๐Ÿฉ','๐Ÿฆฎ','๐Ÿ•โ€๐Ÿฆบ','๐Ÿˆ','๐Ÿˆโ€โฌ›','๐Ÿ“','๐Ÿฆƒ','๐Ÿฆค','๐Ÿฆš','๐Ÿฆœ','๐Ÿฆข','๐Ÿฆฉ','๐Ÿ•Š๏ธ','๐Ÿ‡','๐Ÿฆ','๐Ÿฆจ','๐Ÿฆก','๐Ÿฆฆ','๐Ÿฆฅ','๐Ÿ','๐Ÿ€','๐Ÿฟ๏ธ','๐Ÿฆ”','๐Ÿพ','๐Ÿ‰','๐Ÿฒ'], food: ['๐Ÿ','๐ŸŽ','๐Ÿ','๐ŸŠ','๐Ÿ‹','๐ŸŒ','๐Ÿ‰','๐Ÿ‡','๐Ÿ“','๐Ÿซ','๐Ÿˆ','๐Ÿ’','๐Ÿ‘','๐Ÿฅญ','๐Ÿ','๐Ÿฅฅ','๐Ÿฅ','๐Ÿ…','๐Ÿ†','๐Ÿฅ‘','๐Ÿฅฆ','๐Ÿฅฌ','๐Ÿฅ’','๐ŸŒถ๏ธ','๐ŸŒฝ','๐Ÿฅ•','๐Ÿซ’','๐Ÿง„','๐Ÿง…','๐Ÿฅ”','๐Ÿ ','๐Ÿฅ','๐Ÿž','๐Ÿฅ–','๐Ÿฅจ','๐Ÿง€','๐Ÿฅš','๐Ÿณ','๐Ÿงˆ','๐Ÿฅž','๐Ÿง‡','๐Ÿฅ“','๐Ÿฅฉ','๐Ÿ—','๐Ÿ–','๐Ÿฆด','๐ŸŒญ','๐Ÿ”','๐ŸŸ','๐Ÿ•','๐Ÿซ“','๐Ÿฅช','๐Ÿฅ™','๐Ÿง†','๐ŸŒฎ','๐ŸŒฏ','๐Ÿซ”','๐Ÿฅ—','๐Ÿฅ˜','๐Ÿซ•','๐Ÿฅซ','๐Ÿ','๐Ÿœ','๐Ÿฒ','๐Ÿ›','๐Ÿฃ','๐Ÿฑ','๐ŸฅŸ','๐Ÿฆช','๐Ÿค','๐Ÿ™','๐Ÿš','๐Ÿ˜','๐Ÿฅ','๐Ÿฅ ','๐Ÿฅฎ','๐Ÿข','๐Ÿก','๐Ÿง','๐Ÿจ','๐Ÿฆ','๐Ÿฅง','๐Ÿง','๐Ÿฐ','๐ŸŽ‚','๐Ÿฎ','๐Ÿญ','๐Ÿฌ','๐Ÿซ','๐Ÿฟ','๐Ÿฉ','๐Ÿช','๐ŸŒฐ','๐Ÿฅœ','๐Ÿฏ','๐Ÿฅ›','๐Ÿผ','๐Ÿซ–','โ˜•','๐Ÿต','๐Ÿงƒ','๐Ÿฅค','๐Ÿง‹','๐Ÿถ','๐Ÿบ','๐Ÿป','๐Ÿฅ‚','๐Ÿท','๐Ÿฅƒ','๐Ÿธ','๐Ÿน','๐Ÿง‰','๐Ÿพ','๐ŸงŠ','๐Ÿฅ„','๐Ÿด','๐Ÿฝ๏ธ','๐Ÿฅฃ','๐Ÿฅก','๐Ÿฅข','๐Ÿง‚'], activities: ['โšฝ','๐Ÿ€','๐Ÿˆ','โšพ','๐ŸฅŽ','๐ŸŽพ','๐Ÿ','๐Ÿ‰','๐Ÿฅ','๐ŸŽฑ','๐Ÿช€','๐Ÿ“','๐Ÿธ','๐Ÿ’','๐Ÿ‘','๐Ÿฅ','๐Ÿ','๐Ÿชƒ','๐Ÿฅ…','โ›ณ','๐Ÿช','๐Ÿน','๐ŸŽฃ','๐Ÿคฟ','๐ŸฅŠ','๐Ÿฅ‹','๐ŸŽฝ','๐Ÿ›น','๐Ÿ›ผ','๐Ÿ›ท','โ›ธ๏ธ','๐ŸฅŒ','๐ŸŽฟ','โ›ท๏ธ','๐Ÿ‚','๐Ÿช‚','๐Ÿ‹๏ธ','๐Ÿคผ','๐Ÿคธ','๐Ÿคบ','โ›น๏ธ','๐Ÿคพ','๐ŸŒ๏ธ','๐Ÿ‡','๐Ÿง˜','๐Ÿ„','๐ŸŠ','๐Ÿคฝ','๐Ÿšฃ','๐Ÿง—','๐Ÿšต','๐Ÿšด','๐Ÿ†','๐Ÿฅ‡','๐Ÿฅˆ','๐Ÿฅ‰','๐Ÿ…','๐ŸŽ–๏ธ','๐Ÿต๏ธ','๐ŸŽ—๏ธ','๐ŸŽซ','๐ŸŽŸ๏ธ','๐ŸŽช','๐Ÿคน','๐ŸŽญ','๐Ÿฉฐ','๐ŸŽจ','๐ŸŽฌ','๐ŸŽค','๐ŸŽง','๐ŸŽผ','๐ŸŽน','๐Ÿฅ','๐Ÿช˜','๐ŸŽท','๐ŸŽบ','๐Ÿช—','๐ŸŽธ','๐Ÿช•','๐ŸŽป','๐ŸŽฒ','โ™Ÿ๏ธ','๐ŸŽฏ','๐ŸŽณ','๐ŸŽฎ','๐ŸŽฐ','๐Ÿงฉ'], objects: ['โŒš','๐Ÿ“ฑ','๐Ÿ’ป','โŒจ๏ธ','๐Ÿ–ฅ๏ธ','๐Ÿ–จ๏ธ','๐Ÿ–ฑ๏ธ','๐Ÿ–ฒ๏ธ','๐Ÿ•น๏ธ','๐Ÿ—œ๏ธ','๐Ÿ’ฝ','๐Ÿ’พ','๐Ÿ’ฟ','๐Ÿ“€','๐Ÿ“ผ','๐Ÿ“ท','๐Ÿ“ธ','๐Ÿ“น','๐ŸŽฅ','๐Ÿ“ฝ๏ธ','๐ŸŽž๏ธ','๐Ÿ“ž','โ˜Ž๏ธ','๐Ÿ“Ÿ','๐Ÿ“ ','๐Ÿ“บ','๐Ÿ“ป','๐ŸŽ™๏ธ','๐ŸŽš๏ธ','๐ŸŽ›๏ธ','๐Ÿงญ','โฑ๏ธ','โฒ๏ธ','โฐ','๐Ÿ•ฐ๏ธ','โŒ›','๐Ÿ“ก','๐Ÿ”‹','๐Ÿ”Œ','๐Ÿ’ก','๐Ÿ”ฆ','๐Ÿ•ฏ๏ธ','๐Ÿช”','๐Ÿงฏ','๐Ÿ›ข๏ธ','๐Ÿ’ธ','๐Ÿ’ต','๐Ÿ’ด','๐Ÿ’ถ','๐Ÿ’ท','๐Ÿช™','๐Ÿ’ฐ','๐Ÿ’ณ','๐Ÿ’Ž','โš–๏ธ','๐Ÿชœ','๐Ÿงฐ','๐Ÿช›','๐Ÿ”ง','๐Ÿ”จ','โš’๏ธ','๐Ÿ› ๏ธ','โ›๏ธ','๐Ÿชš','๐Ÿ”ฉ','โš™๏ธ','๐Ÿชค','๐Ÿงฑ','โ›“๏ธ','๐Ÿงฒ','๐Ÿ”ซ','๐Ÿ’ฃ','๐Ÿงจ','๐Ÿช“','๐Ÿ”ช','๐Ÿ—ก๏ธ','โš”๏ธ','๐Ÿ›ก๏ธ','๐Ÿšฌ','โšฐ๏ธ','๐Ÿชฆ','โšฑ๏ธ','๐Ÿบ','๐Ÿ”ฎ','๐Ÿ“ฟ','๐Ÿงฟ','๐Ÿ’ˆ','โš—๏ธ','๐Ÿ”ญ','๐Ÿ”ฌ','๐Ÿ•ณ๏ธ','๐Ÿฉป','๐Ÿฉน','๐Ÿฉบ','๐Ÿ’Š','๐Ÿ’‰','๐Ÿฉธ','๐Ÿงฌ','๐Ÿฆ ','๐Ÿงซ','๐Ÿงช','๐ŸŒก๏ธ','๐Ÿงน','๐Ÿช ','๐Ÿงบ','๐Ÿงป','๐Ÿšฝ','๐Ÿšฐ','๐Ÿšฟ','๐Ÿ›','๐Ÿ›€','๐Ÿงผ','๐Ÿชฅ','๐Ÿช’','๐Ÿงฝ','๐Ÿชฃ','๐Ÿงด','๐Ÿชข','๐Ÿงท','๐Ÿงน','๐Ÿงบ','๐Ÿงป','๐Ÿงผ','๐Ÿชฅ','๐Ÿช’','๐Ÿงฝ','๐Ÿชฃ','๐Ÿงด','๐Ÿชข','๐Ÿงท'], symbols: ['โค๏ธ','๐Ÿงก','๐Ÿ’›','๐Ÿ’š','๐Ÿ’™','๐Ÿ’œ','๐Ÿ–ค','๐Ÿค','๐ŸคŽ','๐Ÿ’”','โฃ๏ธ','๐Ÿ’•','๐Ÿ’ž','๐Ÿ’“','๐Ÿ’—','๐Ÿ’–','๐Ÿ’˜','๐Ÿ’','๐Ÿ’Ÿ','โ˜ฎ๏ธ','โœ๏ธ','โ˜ช๏ธ','๐Ÿ•‰๏ธ','โ˜ธ๏ธ','โœก๏ธ','๐Ÿ”ฏ','๐Ÿ•Ž','โ˜ฏ๏ธ','โ˜ฆ๏ธ','๐Ÿ›','โ›Ž','โ™ˆ','โ™‰','โ™Š','โ™‹','โ™Œ','โ™','โ™Ž','โ™','โ™','โ™‘','โ™’','โ™“','๐Ÿ†”','โš›๏ธ','๐Ÿ‰‘','โ˜ข๏ธ','โ˜ฃ๏ธ','๐Ÿ“ด','๐Ÿ“ณ','๐Ÿˆถ','๐Ÿˆš','๐Ÿˆธ','๐Ÿˆบ','๐Ÿˆท๏ธ','โœด๏ธ','๐Ÿ†š','๐Ÿ’ฎ','๐Ÿ‰','ใŠ™๏ธ','ใŠ—๏ธ','๐Ÿˆด','๐Ÿˆต','๐Ÿˆน','๐Ÿˆฒ','๐Ÿ…ฐ๏ธ','๐Ÿ…ฑ๏ธ','๐Ÿ†Ž','๐Ÿ†‘','๐Ÿ…พ๏ธ','๐Ÿ†˜','โŒ','โญ•','๐Ÿ›‘','โ›”','๐Ÿ“›','๐Ÿšซ','๐Ÿ’ฏ','๐Ÿ’ข','โ™จ๏ธ','๐Ÿšท','๐Ÿšฏ','๐Ÿšณ','๐Ÿšฑ','๐Ÿ”ž','๐Ÿ“ต','๐Ÿšญ','โ—','โ•','โ“','โ”','โ€ผ๏ธ','โ‰๏ธ','๐Ÿ”…','๐Ÿ”†','ใ€ฝ๏ธ','โš ๏ธ','๐Ÿšธ','๐Ÿ”ฑ','โšœ๏ธ','๐Ÿ”ฐ','โ™ป๏ธ','โœ…','๐Ÿˆฏ','๐Ÿ’น','โ‡๏ธ','โœณ๏ธ','โŽ','๐ŸŒ','๐Ÿ’ ','โ“‚๏ธ','๐ŸŒ€','๐Ÿ’ค','๐Ÿง','๐Ÿšพ','โ™ฟ','๐Ÿ…ฟ๏ธ','๐Ÿ›—','๐Ÿˆณ','๐Ÿˆ‚๏ธ','๐Ÿ›‚','๐Ÿ›ƒ','๐Ÿ›„','๐Ÿ›…','๐Ÿšฐ','๐Ÿšน','๐Ÿšบ','๐Ÿšผ','โšง','๐Ÿšป','๐Ÿšฎ','๐ŸŽฆ','๐Ÿ“ถ','๐Ÿˆ','๐Ÿ”ฃ','โ„น๏ธ','๐Ÿ”ค','๐Ÿ”ก','๐Ÿ” ','๐Ÿ†–','๐Ÿ†—','๐Ÿ†™','๐Ÿ†’','๐Ÿ†•','๐Ÿ†“','0๏ธโƒฃ','1๏ธโƒฃ','2๏ธโƒฃ','3๏ธโƒฃ','4๏ธโƒฃ','5๏ธโƒฃ','6๏ธโƒฃ','7๏ธโƒฃ','8๏ธโƒฃ','9๏ธโƒฃ','๐Ÿ”Ÿ','๐Ÿ”ข','#๏ธโƒฃ','*๏ธโƒฃ','โ๏ธ','โ–ถ๏ธ','โธ๏ธ','โฏ๏ธ','โน๏ธ','โบ๏ธ','โญ๏ธ','โฎ๏ธ','โฉ','โช','โซ','โฌ','โ—€๏ธ','๐Ÿ”ผ','๐Ÿ”ฝ','โžก๏ธ','โฌ…๏ธ','โฌ†๏ธ','โฌ‡๏ธ','โ†—๏ธ','โ†˜๏ธ','โ†™๏ธ','โ†–๏ธ','โ†•๏ธ','โ†”๏ธ','โ†ช๏ธ','โ†ฉ๏ธ','โคด๏ธ','โคต๏ธ','๐Ÿ”€','๐Ÿ”','๐Ÿ”‚','๐Ÿ”„','๐Ÿ”ƒ','๐ŸŽต','๐ŸŽถ','โž•','โž–','โž—','โœ–๏ธ','โ™พ๏ธ','๐Ÿ’ฒ','๐Ÿ’ฑ','โ„ข๏ธ','ยฉ๏ธ','ยฎ๏ธ','ใ€ฐ๏ธ','โžฐ','โžฟ','๐Ÿ”š','๐Ÿ”™','๐Ÿ”›','๐Ÿ”','๐Ÿ”œ','โœ”๏ธ','โ˜‘๏ธ','๐Ÿ”˜','๐Ÿ”ด','๐ŸŸ ','๐ŸŸก','๐ŸŸข','๐Ÿ”ต','๐ŸŸฃ','โšซ','โšช','๐ŸŸค','๐Ÿ”บ','๐Ÿ”ป','๐Ÿ”ธ','๐Ÿ”น','๐Ÿ”ถ','๐Ÿ”ท','๐Ÿ”ณ','๐Ÿ”ฒ','โ–ช๏ธ','โ–ซ๏ธ','โ—พ','โ—ฝ','โ—ผ๏ธ','โ—ป๏ธ','๐ŸŸฅ','๐ŸŸง','๐ŸŸจ','๐ŸŸฉ','๐ŸŸฆ','๐ŸŸช','โฌ›','โฌœ','๐ŸŸซ'], flags: ['๐Ÿ','๐Ÿšฉ','๐ŸŽŒ','๐Ÿด','๐Ÿณ๏ธ','๐Ÿณ๏ธโ€๐ŸŒˆ','๐Ÿณ๏ธโ€โšง๏ธ','๐Ÿดโ€โ˜ ๏ธ','๐Ÿ‡ฟ๐Ÿ‡ฆ','๐Ÿ‡ธ๐Ÿ‡ฝ','๐Ÿ‡ธ๐Ÿ‡ฐ','๐Ÿ‡ธ๐Ÿ‡ฎ','๐Ÿ‡ธ๐Ÿ‡ฌ','๐Ÿ‡ธ๐Ÿ‡ช','๐Ÿ‡ธ๐Ÿ‡ฉ','๐Ÿ‡ธ๐Ÿ‡ท','๐Ÿ‡ธ๐Ÿ‡ธ','๐Ÿ‡ธ๐Ÿ‡น','๐Ÿ‡ธ๐Ÿ‡ป','๐Ÿ‡ธ๐Ÿ‡พ','๐Ÿ‡ธ๐Ÿ‡ฟ','๐Ÿ‡น๐Ÿ‡จ','๐Ÿ‡น๐Ÿ‡ฉ','๐Ÿ‡น๐Ÿ‡ซ','๐Ÿ‡น๐Ÿ‡ฌ','๐Ÿ‡น๐Ÿ‡ญ','๐Ÿ‡น๐Ÿ‡ฏ','๐Ÿ‡น๐Ÿ‡ฐ','๐Ÿ‡น๐Ÿ‡ฑ','๐Ÿ‡น๐Ÿ‡ฒ','๐Ÿ‡น๐Ÿ‡ณ','๐Ÿ‡น๐Ÿ‡ด','๐Ÿ‡น๐Ÿ‡ท','๐Ÿ‡น๐Ÿ‡น','๐Ÿ‡น๐Ÿ‡ป','๐Ÿ‡น๐Ÿ‡ผ','๐Ÿ‡น๐Ÿ‡ฟ','๐Ÿ‡บ๐Ÿ‡ฆ','๐Ÿ‡บ๐Ÿ‡ฌ','๐Ÿ‡บ๐Ÿ‡ณ','๐Ÿ‡บ๐Ÿ‡ธ','๐Ÿ‡บ๐Ÿ‡พ','๐Ÿ‡บ๐Ÿ‡ฟ','๐Ÿ‡ป๐Ÿ‡ฆ','๐Ÿ‡ป๐Ÿ‡จ','๐Ÿ‡ป๐Ÿ‡ช','๐Ÿ‡ป๐Ÿ‡ฌ','๐Ÿ‡ป๐Ÿ‡ฎ','๐Ÿ‡ป๐Ÿ‡ณ','๐Ÿ‡ป๐Ÿ‡บ','๐Ÿ‡ผ๐Ÿ‡ซ','๐Ÿ‡ผ๐Ÿ‡ธ','๐Ÿ‡ฝ๐Ÿ‡ฐ','๐Ÿ‡พ๐Ÿ‡ช','๐Ÿ‡พ๐Ÿ‡น','๐Ÿ‡ฟ๐Ÿ‡ฆ','๐Ÿ‡ฟ๐Ÿ‡ฒ','๐Ÿ‡ฟ๐Ÿ‡ผ'] }; document.querySelectorAll('.emoji-cat').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.emoji-cat').forEach(b => b.classList.remove('active')); btn.classList.add('active'); renderEmojis(btn.dataset.cat); }); }); function renderEmojis(category) { const emojis = emojiSets[category] || []; DOM.emojiGrid.innerHTML = emojis.map(e => ``).join(''); DOM.emojiGrid.querySelectorAll('.emoji-item').forEach(btn => { btn.addEventListener('click', () => { DOM.messageInput.value += btn.textContent; DOM.messageInput.focus(); DOM.messageInput.dispatchEvent(new Event('input')); DOM.emojiPicker.classList.add('hidden'); }); }); } // ============================================= // Load More Messages // ============================================= DOM.loadOlderBtn.addEventListener('click', () => { if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) { State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 })); } }); // ============================================= // Online Users Modal // ============================================= DOM.onlineBtn.addEventListener('click', () => { renderOnlineUsers(); DOM.onlineModal.classList.remove('hidden'); }); document.querySelectorAll('[data-close="online-modal"]').forEach(btn => { btn.addEventListener('click', () => DOM.onlineModal.classList.add('hidden')); }); function updateOnlineBadge() { DOM.onlineBadge.textContent = State.onlineUsers.size; } function renderOnlineUsers() { DOM.onlineUsersList.innerHTML = ''; if (State.onlineUsers.size === 0) { DOM.onlineUsersList.innerHTML = '
No users online
'; DOM.onlineTotal.textContent = '0 users online'; return; } State.onlineUsers.forEach(u => { const div = document.createElement('div'); div.className = 'user-item'; div.innerHTML = `
${u.display_name || u.username}
${u.status || 'online'}
`; DOM.onlineUsersList.appendChild(div); }); DOM.onlineTotal.textContent = `${State.onlineUsers.size} users online`; } // ============================================= // Settings Modal // ============================================= document.getElementById('settings-btn').addEventListener('click', openSettings); document.querySelectorAll('[data-close="settings-modal"]').forEach(btn => { btn.addEventListener('click', () => DOM.settingsModal.classList.add('hidden')); }); document.getElementById('save-settings').addEventListener('click', saveSettings); function openSettings() { DOM.settingsDisplayName.value = State.user?.display_name || ''; DOM.settingsUsername.value = State.user?.username || ''; if (State.user?.avatar_path) { DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(State.user.avatar_path)}?token=${encodeURIComponent(State.token)}`; DOM.settingsAvatarImg.classList.remove('hidden'); DOM.settingsAvatarPreview.classList.add('hidden'); } else { DOM.settingsAvatarImg.classList.add('hidden'); DOM.settingsAvatarPreview.classList.remove('hidden'); } // Theme selection DOM.themeOptions.forEach(opt => { opt.classList.toggle('active', opt.dataset.theme === State.theme); }); DOM.customThemeSettings.classList.toggle('hidden', State.theme !== 'custom'); if (State.theme === 'custom') { DOM.customSelfColor.value = State.customColors.self; DOM.customOtherColor.value = State.customColors.other; DOM.customTextColor.value = State.customColors.text; DOM.selfColorHex.textContent = State.customColors.self; DOM.otherColorHex.textContent = State.customColors.other; DOM.textColorHex.textContent = State.customColors.text; } DOM.settingsModal.classList.remove('hidden'); } async function saveSettings() { const newName = DOM.settingsDisplayName.value.trim(); if (newName && newName !== State.user?.display_name) { await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, { method: 'PATCH', headers: { 'X-Auth-Token': State.token } }); State.user.display_name = newName; updateHeader(); showToast('Profile updated', 'success'); } // Avatar const avatarFile = DOM.avatarInput.files[0]; if (avatarFile) { const formData = new FormData(); formData.append('file', avatarFile); const res = await fetch(`${API}/api/profile/avatar`, { method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token } }); if (res.ok) { const data = await res.json(); State.user.avatar_path = data.avatar_path; DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(data.avatar_path)}?token=${encodeURIComponent(State.token)}`; DOM.settingsAvatarImg.classList.remove('hidden'); DOM.settingsAvatarPreview.classList.add('hidden'); showToast('Avatar updated', 'success'); } } // Theme const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light'; if (selectedTheme !== State.theme || selectedTheme === 'custom') { if (selectedTheme === 'custom') { State.customColors.self = DOM.customSelfColor.value; State.customColors.other = DOM.customOtherColor.value; State.customColors.text = DOM.customTextColor.value; localStorage.setItem('custom-self', State.customColors.self); localStorage.setItem('custom-other', State.customColors.other); localStorage.setItem('custom-text', State.customColors.text); } applyTheme(selectedTheme); } DOM.settingsModal.classList.add('hidden'); } // Custom color picker listeners DOM.customSelfColor.addEventListener('input', () => DOM.selfColorHex.textContent = DOM.customSelfColor.value); DOM.customOtherColor.addEventListener('input', () => DOM.otherColorHex.textContent = DOM.customOtherColor.value); DOM.customTextColor.addEventListener('input', () => DOM.textColorHex.textContent = DOM.customTextColor.value); // Theme selection clicks DOM.themeOptions.forEach(opt => { opt.addEventListener('click', () => { DOM.themeOptions.forEach(o => o.classList.remove('active')); opt.classList.add('active'); DOM.customThemeSettings.classList.toggle('hidden', opt.dataset.theme !== 'custom'); if (opt.dataset.theme === 'custom') { DOM.customSelfColor.value = State.customColors.self; DOM.customOtherColor.value = State.customColors.other; DOM.customTextColor.value = State.customColors.text; DOM.selfColorHex.textContent = State.customColors.self; DOM.otherColorHex.textContent = State.customColors.other; DOM.textColorHex.textContent = State.customColors.text; } }); }); // Avatar upload button DOM.avatarUploadBtn.addEventListener('click', () => DOM.avatarInput.click()); DOM.avatarInput.addEventListener('change', () => { const file = DOM.avatarInput.files[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { DOM.settingsAvatarImg.src = e.target.result; DOM.settingsAvatarImg.classList.remove('hidden'); DOM.settingsAvatarPreview.classList.add('hidden'); }; reader.readAsDataURL(file); } }); // ============================================= // Utility // ============================================= function generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); } function truncate(str, n) { if (!str) return ''; return str.length > n ? str.substr(0, n - 1) + 'โ€ฆ' : str; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatTime(ms) { const date = new Date(ms); const now = new Date(); const diff = now - date; if (diff < 60000) return 'now'; if (date.toDateString() === now.toDateString()) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function formatFileSize(bytes) { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function autoResize() { DOM.messageInput.style.height = 'auto'; DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px'; } // ============================================= // Initialization // ============================================= async function init() { if (State.token) { try { const res = await fetch(`${API}/api/auth/verify`, { headers: { 'X-Auth-Token': State.token } }); if (res.ok) { const data = await res.json(); State.user = data.user; showChat(); connectWebSocket(); return; } } catch (e) {} } showAuth(); } function showAuth() { DOM.authScreen.classList.add('active'); DOM.chatScreen.classList.remove('active'); } function showChat() { DOM.authScreen.classList.remove('active'); DOM.chatScreen.classList.add('active'); updateHeader(); } function updateHeader() { if (State.user) { DOM.headerDisplayName.textContent = State.user.display_name; } } // Password visibility toggle document.querySelectorAll('.input-toggle').forEach(btn => { btn.addEventListener('click', () => { const targetId = btn.dataset.toggle; const input = document.getElementById(targetId); if (input) { input.type = input.type === 'password' ? 'text' : 'password'; const icon = btn.querySelector('i'); if (icon) icon.className = input.type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash'; } }); }); // Logout button document.getElementById('logout-btn').addEventListener('click', async () => { if (State.token) { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: { 'X-Auth-Token': State.token } }); } if (State.ws) State.ws.close(); State.ws = null; localStorage.removeItem('token'); State.token = null; State.user = null; State.messages.clear(); State.messageOrder = []; State.onlineUsers.clear(); DOM.messagesList.innerHTML = ''; DOM.emptyState.style.display = 'flex'; showAuth(); updateOnlineBadge(); }); // Request notification permission if (window.Notification && Notification.permission === 'default') { Notification.requestPermission(); } // Expose public methods window.InfinityChat = { replyTo, editMessage, deleteMessage }; init(); })();