Spaces:
Sleeping
Sleeping
Download static/script.js from smodusermc/InfinityChat: direct link, hf CLI and curl.
- Browser
- Download file 49.8 kB
-
https://huggingface.co/spaces/smodusermc/InfinityChat/resolve/fcdcaad02ebb70cbbac1a149f45ddcfd5b308d8f/static/script.js
- Command line
-
hf download hf://spaces/smodusermc/InfinityChat@fcdcaad02ebb70cbbac1a149f45ddcfd5b308d8f/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/smodusermc/InfinityChat/resolve/fcdcaad02ebb70cbbac1a149f45ddcfd5b308d8f/static/script.js
49.8 kB
| // 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 = `<i class="fas fa-${icon}"></i><span>${message}</span>`; | |
| 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 '<div class="message-bubble deleted">This message was deleted</div>'; | |
| 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 += `<div class="reply-indicator"><i class="fas fa-reply"></i><span>${reply.sender_username}: ${truncate(reply.content, 50)}</span></div>`; | |
| } | |
| html += `<div class="message-bubble">`; | |
| html += `<div class="message-text">${escapeHtml(msg.content)}</div>`; | |
| if (msg.file_path) html += buildAttachmentHTML(msg); | |
| html += `<div class="message-meta">`; | |
| html += `<span class="message-time">${formatTime(msg.timestamp_ms)}</span>`; | |
| if (msg.is_edited) html += ' <span class="edited-label">edited</span>'; | |
| if (isSelf) { | |
| const icon = msg.status === 'read' ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>'; | |
| html += `<span class="message-status ${msg.status==='read'?'read':''}">${icon}</span>`; | |
| } | |
| html += `</div><div class="message-actions">`; | |
| html += `<button onclick="InfinityChat.replyTo(${msg.id})"><i class="fas fa-reply"></i></button>`; | |
| if (isSelf) { | |
| html += `<button onclick="InfinityChat.editMessage(${msg.id})"><i class="fas fa-pen"></i></button>`; | |
| html += `<button onclick="InfinityChat.deleteMessage(${msg.id})"><i class="fas fa-trash"></i></button>`; | |
| } | |
| html += `</div></div>`; | |
| 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 `<div class="attachment"><img src="${url}" alt="${msg.file_name}" loading="lazy" onclick="window.open(this.src)"></div>`; | |
| } else if (msg.file_type && msg.file_type.startsWith('video/')) { | |
| return `<div class="attachment"><video controls preload="metadata"><source src="${url}" type="${msg.file_type}"></video></div>`; | |
| } else if (msg.file_type && msg.file_type.startsWith('audio/')) { | |
| return `<div class="attachment"><audio controls src="${url}"></audio></div>`; | |
| } else { | |
| return `<div class="attachment-file" onclick="window.open('${url}')"> | |
| <i class="fas fa-file"></i> | |
| <div class="file-info"> | |
| <div class="file-name">${msg.file_name || 'File'}</div> | |
| <div class="file-size">${formatFileSize(msg.file_size)}</div> | |
| </div> | |
| </div>`; | |
| } | |
| } | |
| 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 => `<button class="emoji-item">${e}</button>`).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 = '<div class="loading-state"><span>No users online</span></div>'; | |
| DOM.onlineTotal.textContent = '0 users online'; | |
| return; | |
| } | |
| State.onlineUsers.forEach(u => { | |
| const div = document.createElement('div'); | |
| div.className = 'user-item'; | |
| div.innerHTML = `<div class="user-avatar"><i class="fas fa-user-circle"></i></div> | |
| <div class="user-info"> | |
| <div class="user-name">${u.display_name || u.username}</div> | |
| <div class="user-status-text">${u.status || 'online'}</div> | |
| </div>`; | |
| 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(); | |
| })(); |