// 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 '
'; 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 += ``; } 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 ``; } else if (msg.file_type && msg.file_type.startsWith('video/')) { return ``; } else if (msg.file_type && msg.file_type.startsWith('audio/')) { return ``; } else { return ``; } } 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 = '