Spaces:
Running
Running
Download static/script.js from smodusermc/InfinityChat: direct link, hf CLI and curl.
- Browser
- Download file 58.7 kB
-
https://huggingface.co/spaces/smodusermc/InfinityChat/resolve/refs%2Fpr%2F2/static/script.js
- Command line
-
hf download hf://spaces/smodusermc/InfinityChat@refs/pr/2/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/smodusermc/InfinityChat/resolve/refs%2Fpr%2F2/static/script.js
58.7 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' | |
| }, | |
| prefs: { | |
| autoScroll: localStorage.getItem('pref-autoscroll') !== 'false', | |
| showScrollBtn: localStorage.getItem('pref-scrollbtn') !== 'false', | |
| showLatestBar: localStorage.getItem('pref-latestbar') !== 'false' | |
| }, | |
| latestUnseen: null | |
| }; | |
| // ============================================= | |
| // DOM Cache | |
| // ============================================= | |
| const DOM = { | |
| authScreen: document.getElementById('auth-screen'), | |
| chatScreen: document.getElementById('chat-screen'), | |
| 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'), | |
| 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'), | |
| connectionStatus: document.getElementById('connection-status'), | |
| connectionText: document.getElementById('connection-text'), | |
| 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'), | |
| latestMessageBar: document.getElementById('latest-message-bar'), | |
| lmbAvatar: document.getElementById('lmb-avatar'), | |
| lmbName: document.getElementById('lmb-name'), | |
| lmbText: document.getElementById('lmb-text'), | |
| typingIndicator: document.getElementById('typing-indicator'), | |
| typingText: document.getElementById('typing-text'), | |
| replyPreview: document.getElementById('reply-preview'), | |
| replyUser: document.getElementById('reply-user'), | |
| replyText: document.getElementById('reply-text'), | |
| 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'), | |
| 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'), | |
| settingsModal: document.getElementById('settings-modal'), | |
| onlineModal: document.getElementById('online-modal'), | |
| emojiPicker: document.getElementById('emoji-picker'), | |
| emojiGrid: document.getElementById('emoji-grid'), | |
| 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'), | |
| toggleAutoScroll: document.getElementById('toggle-autoscroll'), | |
| toggleScrollBtn: document.getElementById('toggle-scroll-btn'), | |
| toggleLatestBar: document.getElementById('toggle-latest-bar'), | |
| onlineBtn: document.getElementById('online-btn'), | |
| onlineUsersList: document.getElementById('online-users-list'), | |
| onlineTotal: document.getElementById('online-total'), | |
| toastContainer: document.getElementById('toast-container') | |
| }; | |
| const API = window.location.origin; | |
| // ============================================= | |
| // File URL helper | |
| // ============================================= | |
| function fileUrl(filePath) { | |
| return `${API}/api/download/${encodeURIComponent(filePath)}?t=${encodeURIComponent(State.token)}`; | |
| } | |
| // ============================================= | |
| // Theme | |
| // ============================================= | |
| function applyTheme(theme) { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| State.theme = theme; | |
| localStorage.setItem('theme', theme); | |
| if (theme === 'custom') { | |
| const s = State.customColors.self; | |
| const o = State.customColors.other; | |
| const t = State.customColors.text; | |
| document.documentElement.style.setProperty('--bubble-self', s); | |
| document.documentElement.style.setProperty('--bubble-other', o); | |
| document.documentElement.style.setProperty('--bubble-other-text', t); | |
| 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(s) ? '#111827' : '#FFFFFF'); | |
| } else { | |
| 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); | |
| // ============================================= | |
| // Preferences | |
| // ============================================= | |
| function applyPrefs() { | |
| if (DOM.toggleAutoScroll) DOM.toggleAutoScroll.checked = State.prefs.autoScroll; | |
| if (DOM.toggleScrollBtn) DOM.toggleScrollBtn.checked = State.prefs.showScrollBtn; | |
| if (DOM.toggleLatestBar) DOM.toggleLatestBar.checked = State.prefs.showLatestBar; | |
| updateScrollUI(); | |
| } | |
| function savePrefs() { | |
| localStorage.setItem('pref-autoscroll', State.prefs.autoScroll); | |
| localStorage.setItem('pref-scrollbtn', State.prefs.showScrollBtn); | |
| localStorage.setItem('pref-latestbar', State.prefs.showLatestBar); | |
| } | |
| if (DOM.toggleAutoScroll) { | |
| DOM.toggleAutoScroll.addEventListener('change', () => { | |
| State.prefs.autoScroll = DOM.toggleAutoScroll.checked; | |
| savePrefs(); | |
| if (State.prefs.autoScroll) { | |
| scrollToBottom(); | |
| hideLatestBar(); | |
| } | |
| }); | |
| } | |
| if (DOM.toggleScrollBtn) { | |
| DOM.toggleScrollBtn.addEventListener('change', () => { | |
| State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked; | |
| savePrefs(); | |
| updateScrollUI(); | |
| }); | |
| } | |
| if (DOM.toggleLatestBar) { | |
| DOM.toggleLatestBar.addEventListener('change', () => { | |
| State.prefs.showLatestBar = DOM.toggleLatestBar.checked; | |
| savePrefs(); | |
| if (!State.prefs.showLatestBar) hideLatestBar(); | |
| }); | |
| } | |
| // ============================================= | |
| // Timestamp updater | |
| // ============================================= | |
| function startTimestampUpdater() { | |
| setInterval(() => { | |
| document.querySelectorAll('.message-time[data-ts]').forEach(el => { | |
| el.textContent = formatTime(parseInt(el.dataset.ts)); | |
| }); | |
| }, 30000); | |
| } | |
| // ============================================= | |
| // Toast | |
| // ============================================= | |
| function showToast(message, type = 'info') { | |
| const toast = document.createElement('div'); | |
| toast.className = `toast toast-${type}`; | |
| const icons = { success:'check-circle', error:'exclamation-circle', warning:'exclamation-triangle', info:'info-circle' }; | |
| toast.innerHTML = `<i class="fas fa-${icons[type]||'info-circle'}"></i><span>${message}</span>`; | |
| DOM.toastContainer.appendChild(toast); | |
| setTimeout(() => toast.remove(), 4000); | |
| } | |
| // ============================================= | |
| // Auth | |
| // ============================================= | |
| 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(); | |
| await login( | |
| document.getElementById('login-username').value.trim(), | |
| document.getElementById('login-password').value | |
| ); | |
| }); | |
| 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:'; | |
| State.ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`); | |
| State.ws.onopen = () => { | |
| State.wsState = 'connected'; | |
| updateConnectionUI(); | |
| loadMessages(); | |
| requestOnlineUsers(); | |
| }; | |
| State.ws.onmessage = (e) => { | |
| try { handleWSMessage(JSON.parse(e.data)); } catch(err) { console.error('WS parse error', err); } | |
| }; | |
| 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() { | |
| DOM.connectionStatus.className = 'connection-status'; | |
| const map = { | |
| connected: ['connected', 'Connected'], | |
| reconnecting: ['reconnecting', 'Reconnecting...'], | |
| disconnected: ['disconnected', 'Disconnected'], | |
| connecting: ['reconnecting', 'Connecting...'] | |
| }; | |
| const [cls, txt] = map[State.wsState] || ['disconnected', 'Disconnected']; | |
| DOM.connectionStatus.classList.add(cls); | |
| DOM.connectionText.textContent = txt; | |
| } | |
| 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 | |
| }; | |
| State.onlineUsers.clear(); | |
| msg.online_users.forEach(u => State.onlineUsers.set(u.id, u)); | |
| updateHeader(); | |
| updateOnlineBadge(); | |
| break; | |
| case 'new_message': { | |
| const m = msg.message; | |
| if (State.messages.has(m.id)) { | |
| const ex = State.messages.get(m.id); | |
| ex.status = m.status; | |
| updateMessageBubble(m.id); | |
| return; | |
| } | |
| State.messages.set(m.id, m); | |
| insertMessageIdSorted(m.id); | |
| renderMessage(m, true); | |
| if (State.isAtBottom) { | |
| if (State.prefs.autoScroll) scrollToBottom(); | |
| } else { | |
| // Show latest message bar if not at bottom | |
| State.latestUnseen = m; | |
| showLatestBar(m); | |
| } | |
| if (document.visibilityState === 'visible' && State.isAtBottom) { | |
| sendMarkRead(m.id); | |
| } | |
| if (document.visibilityState !== 'visible' && Notification.permission === 'granted') { | |
| new Notification(m.sender_display_name || m.sender_username, { | |
| body: m.content || 'Sent a file', | |
| icon: '/static/icon.png' | |
| }); | |
| } | |
| break; | |
| } | |
| case 'messages_loaded': { | |
| const sorted = [...msg.messages].sort((a, b) => a.id - b.id); | |
| sorted.forEach(m => { | |
| if (!State.messages.has(m.id)) { | |
| State.messages.set(m.id, m); | |
| insertMessageIdSorted(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 m = State.messages.get(msg.message_id); | |
| m.content = msg.content; | |
| m.is_edited = true; | |
| updateMessageBubble(msg.message_id); | |
| } | |
| break; | |
| case 'message_deleted': | |
| if (State.messages.has(msg.message_id)) { | |
| State.messages.get(msg.message_id).is_deleted = true; | |
| updateMessageBubble(msg.message_id); | |
| } | |
| break; | |
| case 'message_read': | |
| if (State.messages.has(msg.message_id)) { | |
| const m = State.messages.get(msg.message_id); | |
| if (m.sender_id === State.user.id) { | |
| m.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'); | |
| break; | |
| } | |
| } | |
| function insertMessageIdSorted(id) { | |
| let lo = 0, hi = State.messageOrder.length; | |
| while (lo < hi) { | |
| const mid = (lo + hi) >> 1; | |
| if (State.messageOrder[mid] < id) lo = mid + 1; | |
| else hi = mid; | |
| } | |
| if (State.messageOrder[lo] !== id) { | |
| State.messageOrder.splice(lo, 0, id); | |
| } | |
| } | |
| // ============================================= | |
| // Latest message bar | |
| // ============================================= | |
| function showLatestBar(msg) { | |
| if (!State.prefs.showLatestBar || !DOM.latestMessageBar) return; | |
| const name = msg.sender_display_name || msg.sender_username || 'Unknown'; | |
| const text = msg.content || (msg.file_name ? '📎 ' + msg.file_name : '📎 File'); | |
| // Avatar | |
| if (msg.sender_avatar_path) { | |
| DOM.lmbAvatar.innerHTML = `<img src="${fileUrl(msg.sender_avatar_path)}" alt="${escapeHtml(name)}">`; | |
| } else { | |
| DOM.lmbAvatar.textContent = name.charAt(0).toUpperCase(); | |
| DOM.lmbAvatar.style.background = 'var(--primary)'; | |
| } | |
| DOM.lmbName.textContent = name + ':'; | |
| DOM.lmbText.textContent = truncate(text, 40); | |
| DOM.latestMessageBar.classList.remove('hidden'); | |
| } | |
| function hideLatestBar() { | |
| if (DOM.latestMessageBar) DOM.latestMessageBar.classList.add('hidden'); | |
| State.latestUnseen = null; | |
| } | |
| if (DOM.latestMessageBar) { | |
| DOM.latestMessageBar.addEventListener('click', () => { | |
| scrollToBottom(); | |
| hideLatestBar(); | |
| }); | |
| } | |
| // ============================================= | |
| // Scroll UI | |
| // ============================================= | |
| function updateScrollUI() { | |
| if (!State.isAtBottom) { | |
| if (State.prefs.showScrollBtn) { | |
| DOM.scrollBottomBtn.classList.remove('hidden'); | |
| } else { | |
| DOM.scrollBottomBtn.classList.add('hidden'); | |
| } | |
| } else { | |
| DOM.scrollBottomBtn.classList.add('hidden'); | |
| hideLatestBar(); | |
| } | |
| } | |
| DOM.messagesScroll.addEventListener('scroll', () => { | |
| const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll; | |
| State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 80; | |
| updateScrollUI(); | |
| if (State.isAtBottom) { | |
| hideLatestBar(); | |
| if (State.latestUnseen) sendMarkRead(State.latestUnseen.id); | |
| } | |
| }); | |
| DOM.scrollBottomBtn.addEventListener('click', () => { | |
| scrollToBottom(); | |
| hideLatestBar(); | |
| }); | |
| function scrollToBottom() { | |
| DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight; | |
| State.isAtBottom = true; | |
| DOM.scrollBottomBtn.classList.add('hidden'); | |
| } | |
| // ============================================= | |
| // Message Actions | |
| // ============================================= | |
| function loadMessages() { | |
| if (State.ws?.readyState === WebSocket.OPEN) { | |
| State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 })); | |
| } | |
| } | |
| function requestOnlineUsers() { | |
| if (State.ws?.readyState === WebSocket.OPEN) { | |
| State.ws.send(JSON.stringify({ type: 'get_online_users' })); | |
| } | |
| } | |
| function sendMarkRead(messageId) { | |
| if (State.ws?.readyState === WebSocket.OPEN) { | |
| State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId })); | |
| } | |
| } | |
| // ============================================= | |
| // Rendering | |
| // ============================================= | |
| function buildSenderAvatarHTML(msg) { | |
| const name = escapeHtml(msg.sender_display_name || msg.sender_username || '?'); | |
| const initial = (msg.sender_display_name || msg.sender_username || '?').charAt(0).toUpperCase(); | |
| if (msg.sender_avatar_path) { | |
| return `<img class="msg-avatar" src="${fileUrl(msg.sender_avatar_path)}" alt="${name}" title="${name}">`; | |
| } | |
| return `<div class="msg-avatar-placeholder" title="${name}">${initial}</div>`; | |
| } | |
| function renderMessage(msg, append = false) { | |
| if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return; | |
| const wrapper = createMessageElement(msg); | |
| if (append) { | |
| DOM.messagesList.appendChild(wrapper); | |
| } else { | |
| const ids = State.messageOrder; | |
| const idx = ids.indexOf(msg.id); | |
| const nextId = ids[idx + 1]; | |
| if (nextId) { | |
| const nextEl = DOM.messagesList.querySelector(`[data-id="${nextId}"]`); | |
| if (nextEl) { DOM.messagesList.insertBefore(wrapper, nextEl); return; } | |
| } | |
| DOM.messagesList.appendChild(wrapper); | |
| } | |
| } | |
| function createMessageElement(msg) { | |
| const isSelf = msg.sender_id === State.user?.id; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = `message-wrapper ${isSelf ? 'self' : 'other'}`; | |
| wrapper.dataset.id = msg.id; | |
| wrapper.innerHTML = buildMessageHTML(msg); | |
| return wrapper; | |
| } | |
| function buildMessageHTML(msg) { | |
| if (msg.is_deleted) { | |
| return '<div class="message-bubble deleted"><i class="fas fa-ban"></i> This message was deleted</div>'; | |
| } | |
| const isSelf = msg.sender_id === State.user?.id; | |
| const displayName = escapeHtml(msg.sender_display_name || msg.sender_username || 'Unknown'); | |
| let html = ''; | |
| // Sender row: avatar + name, always shown | |
| if (!isSelf) { | |
| html += `<div class="msg-sender-row"> | |
| ${buildSenderAvatarHTML(msg)} | |
| <span class="msg-sender-name">${displayName}</span> | |
| </div>`; | |
| } else { | |
| html += `<div class="msg-sender-row msg-sender-row-self"> | |
| <span class="msg-sender-name">${displayName}</span> | |
| ${buildSenderAvatarHTML(msg)} | |
| </div>`; | |
| } | |
| // Reply indicator | |
| if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) { | |
| const reply = State.messages.get(msg.reply_to_id); | |
| const rName = escapeHtml(reply.sender_display_name || reply.sender_username || 'Unknown'); | |
| const rText = escapeHtml(truncate(reply.content || (reply.file_name ? '📎 ' + reply.file_name : ''), 60)); | |
| html += `<div class="reply-indicator"> | |
| <i class="fas fa-reply"></i> | |
| <span><strong>${rName}:</strong> ${rText}</span> | |
| </div>`; | |
| } | |
| html += `<div class="message-bubble">`; | |
| // Text content | |
| if (msg.content) { | |
| html += `<div class="message-text">${escapeHtml(msg.content)}</div>`; | |
| } | |
| // Attachment | |
| if (msg.file_path) { | |
| html += buildAttachmentHTML(msg); | |
| } | |
| // Meta | |
| html += `<div class="message-meta"> | |
| <span class="message-time" data-ts="${msg.timestamp_ms}">${formatTime(msg.timestamp_ms)}</span>`; | |
| if (msg.is_edited) html += ` <span class="edited-label">edited</span>`; | |
| if (isSelf) { | |
| const isRead = msg.status === 'read'; | |
| html += `<span class="message-status${isRead ? ' read' : ''}"> | |
| <i class="fas fa-check${isRead ? '-double' : ''}"></i> | |
| </span>`; | |
| } | |
| html += `</div>`; | |
| // Actions - always visible | |
| html += `<div class="message-actions"> | |
| <button onclick="InfinityChat.replyTo(${msg.id})" title="Reply"><i class="fas fa-reply"></i></button>`; | |
| if (isSelf) { | |
| html += `<button onclick="InfinityChat.editMessage(${msg.id})" title="Edit"><i class="fas fa-pen"></i></button> | |
| <button onclick="InfinityChat.deleteMessage(${msg.id})" title="Delete"><i class="fas fa-trash"></i></button>`; | |
| } | |
| html += `</div>`; | |
| html += `</div>`; // close message-bubble | |
| return html; | |
| } | |
| function buildAttachmentHTML(msg) { | |
| const url = fileUrl(msg.file_path); | |
| const ft = (msg.file_type || '').toLowerCase(); | |
| const fname = escapeHtml(msg.file_name || 'File'); | |
| if (ft.startsWith('image/')) { | |
| return `<div class="attachment"> | |
| <img src="${url}" | |
| alt="${fname}" | |
| loading="lazy" | |
| onclick="window.open('${url}', '_blank')" | |
| onerror="this.parentElement.innerHTML='<div class=\\'attachment-file\\' onclick=\\'window.open(\\'${url}\\',\\'_blank\\')\\'>'+ | |
| '<i class=\\'fas fa-image\\'></i>'+ | |
| '<div class=\\'file-info\\'>'+ | |
| '<div class=\\'file-name\\'>${fname}</div>'+ | |
| '<div class=\\'file-size\\'>${formatFileSize(msg.file_size)}</div>'+ | |
| '</div></div>'"> | |
| </div>`; | |
| } | |
| if (ft.startsWith('video/')) { | |
| return `<div class="attachment"> | |
| <video controls preload="metadata"> | |
| <source src="${url}" type="${escapeHtml(msg.file_type)}"> | |
| </video> | |
| </div>`; | |
| } | |
| if (ft.startsWith('audio/')) { | |
| return `<div class="attachment"><audio controls src="${url}"></audio></div>`; | |
| } | |
| return `<div class="attachment-file" onclick="window.open('${url}', '_blank')"> | |
| <i class="fas fa-file-alt"></i> | |
| <div class="file-info"> | |
| <div class="file-name">${fname}</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) return; | |
| const isSelf = msg.sender_id === State.user?.id; | |
| el.className = `message-wrapper ${isSelf ? 'self' : 'other'}`; | |
| el.innerHTML = buildMessageHTML(msg); | |
| } | |
| function renderAllMessages() { | |
| const wasAtBottom = State.isAtBottom; | |
| const prevScrollHeight = DOM.messagesScroll.scrollHeight; | |
| DOM.messagesList.innerHTML = ''; | |
| if (State.messageOrder.length === 0) { | |
| DOM.emptyState.style.display = 'flex'; | |
| return; | |
| } | |
| DOM.emptyState.style.display = 'none'; | |
| State.messageOrder.forEach(id => { | |
| const msg = State.messages.get(id); | |
| if (msg) renderMessage(msg, true); | |
| }); | |
| if (wasAtBottom && State.prefs.autoScroll) { | |
| scrollToBottom(); | |
| } else if (!wasAtBottom) { | |
| DOM.messagesScroll.scrollTop += (DOM.messagesScroll.scrollHeight - prevScrollHeight); | |
| } else { | |
| scrollToBottom(); | |
| } | |
| } | |
| // ============================================= | |
| // Compose | |
| // ============================================= | |
| DOM.composeForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| sendMessage(); | |
| }); | |
| DOM.messageInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| 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 = ''; | |
| DOM.sendBtn.disabled = true; | |
| clearReply(); | |
| clearPendingFile(); | |
| autoResize(); | |
| sendTyping(false); | |
| } | |
| function sendTyping(isTyping) { | |
| if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return; | |
| clearTimeout(window.__typingTimer); | |
| State.ws.send(JSON.stringify({ type: 'typing', is_typing: isTyping })); | |
| if (isTyping) { | |
| window.__typingTimer = setTimeout(() => sendTyping(false), 2000); | |
| } | |
| } | |
| 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 || u.username).join(', ') + | |
| (users.length === 1 ? ' is typing...' : ' are typing...'); | |
| } | |
| } | |
| // ============================================= | |
| // Reply | |
| // ============================================= | |
| function replyTo(messageId) { | |
| const msg = State.messages.get(messageId); | |
| if (!msg) return; | |
| State.replyTo = msg; | |
| DOM.replyUser.textContent = msg.sender_display_name || msg.sender_username; | |
| DOM.replyText.textContent = truncate(msg.content || (msg.file_name ? '📎 ' + msg.file_name : ''), 60); | |
| 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()) { | |
| State.ws?.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() })); | |
| } | |
| } | |
| function deleteMessage(messageId) { | |
| if (confirm('Permanently delete this message?')) { | |
| State.ws?.send(JSON.stringify({ type: 'delete_message', message_id: messageId })); | |
| } | |
| } | |
| // ============================================= | |
| // File Upload | |
| // ============================================= | |
| 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; | |
| 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 blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size)); | |
| 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.type, | |
| file_name: data.file_name, | |
| file_size: data.file_size | |
| }; | |
| showUploadPreview(data.file_name); | |
| DOM.uploadProgress.classList.add('hidden'); | |
| DOM.sendBtn.disabled = false; | |
| return; | |
| } | |
| } catch (err) { | |
| showToast('Upload failed: ' + err.message, 'error'); | |
| DOM.uploadProgress.classList.add('hidden'); | |
| return; | |
| } | |
| } | |
| 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; | |
| } | |
| function clearPendingFile() { | |
| State.pendingFile = null; | |
| DOM.uploadPreview.classList.add('hidden'); | |
| } | |
| DOM.cancelUpload.addEventListener('click', () => { | |
| clearPendingFile(); | |
| DOM.sendBtn.disabled = DOM.messageInput.value.trim().length === 0; | |
| }); | |
| // ============================================= | |
| // Emoji Picker | |
| // ============================================= | |
| 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" type="button">${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 | |
| // ============================================= | |
| DOM.loadOlderBtn.addEventListener('click', () => { | |
| if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) { | |
| State.isAtBottom = false; | |
| State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 })); | |
| } | |
| }); | |
| // ============================================= | |
| // Online Users | |
| // ============================================= | |
| 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'; | |
| let avatarHtml; | |
| if (u.avatar_path) { | |
| avatarHtml = `<img src="${fileUrl(u.avatar_path)}" alt="${escapeHtml(u.display_name || u.username)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`; | |
| } else { | |
| avatarHtml = (u.display_name || u.username || '?').charAt(0).toUpperCase(); | |
| } | |
| div.innerHTML = ` | |
| <div class="user-avatar">${avatarHtml}</div> | |
| <div class="user-info"> | |
| <div class="user-name">${escapeHtml(u.display_name || u.username)}</div> | |
| <div class="user-status-text"><span style="color:var(--success)">●</span> Online</div> | |
| </div>`; | |
| DOM.onlineUsersList.appendChild(div); | |
| }); | |
| DOM.onlineTotal.textContent = `${State.onlineUsers.size} user${State.onlineUsers.size !== 1 ? 's' : ''} online`; | |
| } | |
| // ============================================= | |
| // Settings | |
| // ============================================= | |
| 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 = fileUrl(State.user.avatar_path); | |
| DOM.settingsAvatarImg.classList.remove('hidden'); | |
| DOM.settingsAvatarPreview.classList.add('hidden'); | |
| } else { | |
| DOM.settingsAvatarImg.classList.add('hidden'); | |
| DOM.settingsAvatarPreview.classList.remove('hidden'); | |
| } | |
| 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; | |
| } | |
| // Load prefs into toggles | |
| applyPrefs(); | |
| DOM.settingsModal.classList.remove('hidden'); | |
| } | |
| async function saveSettings() { | |
| const newName = DOM.settingsDisplayName.value.trim(); | |
| if (newName && newName !== State.user?.display_name) { | |
| try { | |
| const res = await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, { | |
| method: 'PATCH', | |
| headers: { 'X-Auth-Token': State.token } | |
| }); | |
| if (res.ok) { | |
| State.user.display_name = newName; | |
| updateHeader(); | |
| showToast('Display name updated', 'success'); | |
| } | |
| } catch (e) { | |
| showToast('Failed to update name', 'error'); | |
| } | |
| } | |
| const avatarFile = DOM.avatarInput.files[0]; | |
| if (avatarFile) { | |
| const formData = new FormData(); | |
| formData.append('file', avatarFile); | |
| try { | |
| 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 = fileUrl(data.avatar_path); | |
| DOM.settingsAvatarImg.classList.remove('hidden'); | |
| DOM.settingsAvatarPreview.classList.add('hidden'); | |
| updateHeader(); | |
| showToast('Avatar updated', 'success'); | |
| } | |
| } catch (e) { | |
| showToast('Failed to update avatar', 'error'); | |
| } | |
| } | |
| // Save prefs from toggles | |
| if (DOM.toggleAutoScroll) State.prefs.autoScroll = DOM.toggleAutoScroll.checked; | |
| if (DOM.toggleScrollBtn) State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked; | |
| if (DOM.toggleLatestBar) State.prefs.showLatestBar = DOM.toggleLatestBar.checked; | |
| savePrefs(); | |
| const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light'; | |
| 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'); | |
| } | |
| 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; }); | |
| 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; | |
| } | |
| }); | |
| }); | |
| 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) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| function formatTime(ms) { | |
| if (!ms) return ''; | |
| const date = new Date(ms); | |
| const now = new Date(); | |
| const diff = now - date; | |
| if (diff < 60000) return 'just now'; | |
| if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; | |
| if (date.toDateString() === now.toDateString()) { | |
| return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| const yesterday = new Date(now); | |
| yesterday.setDate(yesterday.getDate() - 1); | |
| if (date.toDateString() === yesterday.toDateString()) { | |
| return 'Yesterday ' + 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'; | |
| } | |
| // ============================================= | |
| // Init | |
| // ============================================= | |
| async function init() { | |
| applyPrefs(); | |
| 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(); | |
| startTimestampUpdater(); | |
| return; | |
| } | |
| } catch (e) {} | |
| localStorage.removeItem('token'); | |
| State.token = null; | |
| } | |
| 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(); | |
| startTimestampUpdater(); | |
| } | |
| function updateHeader() { | |
| if (!State.user) return; | |
| const displayEl = document.getElementById('header-display-name'); | |
| if (displayEl) displayEl.textContent = State.user.display_name || State.user.username; | |
| if (State.user.avatar_path) { | |
| const img = document.getElementById('header-avatar'); | |
| const ph = document.getElementById('header-avatar-placeholder'); | |
| if (img) { img.src = fileUrl(State.user.avatar_path); img.classList.remove('hidden'); } | |
| if (ph) ph.classList.add('hidden'); | |
| } | |
| } | |
| document.querySelectorAll('.input-toggle').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const input = document.getElementById(btn.dataset.toggle); | |
| 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'; | |
| } | |
| }); | |
| }); | |
| document.getElementById('logout-btn').addEventListener('click', async () => { | |
| if (State.token) { | |
| try { | |
| await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: { 'X-Auth-Token': State.token } }); | |
| } catch (e) {} | |
| } | |
| 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(); | |
| State.typingUsers.clear(); | |
| State.cursor = null; | |
| State.hasMore = true; | |
| State.latestUnseen = null; | |
| DOM.messagesList.innerHTML = ''; | |
| DOM.emptyState.style.display = 'flex'; | |
| hideLatestBar(); | |
| showAuth(); | |
| updateOnlineBadge(); | |
| }); | |
| if (window.Notification && Notification.permission === 'default') { | |
| Notification.requestPermission(); | |
| } | |
| window.InfinityChat = { replyTo, editMessage, deleteMessage }; | |
| init(); | |
| })(); |