(function() { 'use strict'; // --- State --- let currentUser = null; let token = localStorage.getItem('token'); let ws = null; let messages = {}; // id -> msg object let messageIds = []; // ordered let oldestMessageId = null; let replyToId = null; let typingTimer = null; let pendingFile = null; // {file_path, file_type, file_name} const API = window.location.origin; // DOM elements const authScreen = document.getElementById('auth-screen'); const chatScreen = document.getElementById('chat-screen'); const authForm = document.getElementById('auth-form'); const authUsername = document.getElementById('auth-username'); const authPassword = document.getElementById('auth-password'); const authDisplayName = document.getElementById('auth-display-name'); const displayNameGroup = document.getElementById('display-name-group'); const authSubmit = document.getElementById('auth-submit'); const authError = document.getElementById('auth-error'); const loginTab = document.getElementById('login-tab'); const signupTab = document.getElementById('signup-tab'); const messagesList = document.getElementById('messages-list'); const composeForm = document.getElementById('compose-form'); const messageInput = document.getElementById('message-input'); const sendBtn = document.getElementById('send-btn'); const attachBtn = document.getElementById('attach-btn'); const fileInput = document.getElementById('file-input'); const replyPreview = document.getElementById('reply-preview'); const replyPreviewText = document.getElementById('reply-preview-text'); const cancelReply = document.getElementById('cancel-reply'); const typingIndicator = document.getElementById('typing-indicator'); const onlineCountSpan = document.getElementById('online-count'); const loadOlderBtn = document.getElementById('load-older-btn'); const settingsBtn = document.getElementById('settings-btn'); const logoutBtn = document.getElementById('logout-btn'); const settingsModal = document.getElementById('settings-modal'); const saveSettingsBtn = document.getElementById('save-settings'); const closeSettingsBtn = document.getElementById('close-settings'); const themeSelect = document.getElementById('theme-select'); const customThemeSettings = document.getElementById('custom-theme-settings'); const customSelfColor = document.getElementById('custom-self-color'); const customOtherColor = document.getElementById('custom-other-color'); const customTextColor = document.getElementById('custom-text-color'); const settingDisplayName = document.getElementById('setting-display-name'); const settingAvatar = document.getElementById('setting-avatar'); const avatarPreview = document.getElementById('avatar-preview'); const uploadProgress = document.getElementById('upload-progress'); const uploadBar = document.getElementById('upload-bar'); const uploadFilename = document.getElementById('upload-filename'); let isLogin = true; // --- Theme --- function applyTheme(name) { document.body.className = name; localStorage.setItem('theme', name); if (name === 'theme-custom') { const self = localStorage.getItem('customSelfColor') || '#25D366'; const other = localStorage.getItem('customOtherColor') || '#E5E5EA'; const txt = localStorage.getItem('customTextColor') || '#000'; document.documentElement.style.setProperty('--bubble-self', self); document.documentElement.style.setProperty('--bubble-other', other); document.documentElement.style.setProperty('--bubble-text-self', txt); document.documentElement.style.setProperty('--bubble-text-other', txt); } } function loadTheme() { const saved = localStorage.getItem('theme') || 'theme-light'; themeSelect.value = saved; if (saved === 'theme-custom') customThemeSettings.classList.remove('hidden'); applyTheme(saved); } // --- Auth --- loginTab.addEventListener('click', ()=>{ isLogin=true; loginTab.classList.add('active'); signupTab.classList.remove('active'); displayNameGroup.style.display='none'; authSubmit.textContent='Login'; }); signupTab.addEventListener('click', ()=>{ isLogin=false; signupTab.classList.add('active'); loginTab.classList.remove('active'); displayNameGroup.style.display='block'; authSubmit.textContent='Sign Up'; }); authForm.addEventListener('submit', async (e)=>{ e.preventDefault(); const u = authUsername.value.trim(), p = authPassword.value, d = authDisplayName.value.trim(); if(!u||!p){ authError.textContent='Fill all fields.'; return; } try{ const url = isLogin ? `${API}/login?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}` : `${API}/signup?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}&display_name=${encodeURIComponent(d)}`; const res = await fetch(url, {method:'POST'}); if(!res.ok) throw new Error((await res.json()).detail||'Auth failed'); const data = await res.json(); token = data.token; currentUser = { id: data.id, username: data.username, display_name: data.display_name, avatar_path: data.avatar_path }; localStorage.setItem('token', token); showChat(); }catch(err){ authError.textContent=err.message; } }); async function autoLogin() { if(!token) return false; try{ const res = await fetch(`${API}/me`, {headers:{token}}); if(!res.ok){ token=null; localStorage.removeItem('token'); return false; } currentUser = await res.json(); return true; }catch{ return false; } } function showChat() { authScreen.classList.add('hidden'); chatScreen.classList.remove('hidden'); connectWS(); loadMessages(); } // --- WebSocket --- function connectWS() { if(ws && ws.readyState===WebSocket.OPEN) return; const proto = location.protocol==='https:'?'wss:':'ws:'; ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`); ws.onopen = ()=>{ console.log('WS open'); }; ws.onmessage = (ev)=> handleWSMessage(JSON.parse(ev.data)); ws.onclose = ()=> setTimeout(connectWS, 3000); ws.onerror = ()=> {}; } function handleWSMessage(msg) { switch(msg.type){ case 'online_users': onlineCountSpan.textContent = `(${msg.users.length} online)`; break; case 'user_online': break; case 'user_offline': break; case 'new_message': if(messages[msg.id]) return; messages[msg.id] = msg; messageIds.push(msg.id); renderMessage(msg); scrollBottom(); if(document.visibilityState==='visible') ws.send(JSON.stringify({type:'mark_read', up_to_message_id:msg.id})); break; case 'messages_batch': if(msg.messages.length>0){ for(const m of msg.messages){ if(!messages[m.id]){ messages[m.id]=m; messageIds.unshift(m.id); } } renderAll(); scrollBottom(); } if(msg.messages.length<50) loadOlderBtn.classList.add('hidden'); else loadOlderBtn.classList.remove('hidden'); break; case 'typing_update': typingIndicator.textContent = msg.is_typing ? `${msg.username} is typing…` : ''; break; case 'edit_message': if(messages[msg.message_id]){ messages[msg.message_id].content = msg.content; messages[msg.message_id].is_edited = true; updateBubble(msg.message_id); } break; case 'delete_message': if(messages[msg.message_id]){ messages[msg.message_id].is_deleted = true; updateBubble(msg.message_id); } break; case 'read_receipt': if(messages[msg.message_id] && messages[msg.message_id].sender_id === currentUser.id){ // Update status to "read" messages[msg.message_id].status = 'read'; updateBubble(msg.message_id); } break; } } // --- Render --- function renderMessage(msg) { const el = document.getElementById(`msg-${msg.id}`); if(el){ updateBubble(msg.id); return; } const div = document.createElement('div'); div.id = `msg-${msg.id}`; div.className = `message-bubble ${msg.sender_id===currentUser.id?'message-self':'message-other'}`; div.innerHTML = buildHTML(msg); messagesList.appendChild(div); } function buildHTML(msg) { if(msg.is_deleted) return `This message was deleted`; let html = ''; if(msg.reply_to_id && messages[msg.reply_to_id]){ const reply = messages[msg.reply_to_id]; html += `
`; } html += ``; html += ``; if(msg.is_edited) html += ` [edited]`; if(msg.file_path) html += renderAttachment(msg); if(msg.sender_id===currentUser.id && !msg.is_deleted){ const statusIcon = msg.status==='read' ? '✓✓' : '✓'; html += `