(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 += `
↩ ${reply.display_name||reply.username}: ${truncate(reply.content,40)}
`; } html += `
${msg.display_name||msg.username}${formatTime(msg.timestamp_ms)}
`; html += `
${escapeHtml(msg.content)}
`; 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 += `
${statusIcon}
`; html += `
`; } return html; } function renderAttachment(msg) { const url = `${API}/download/${msg.file_path}?token=${encodeURIComponent(token)}`; if(msg.file_type.startsWith('image/')) return `
${msg.file_name}
`; if(msg.file_type.startsWith('video/')) return `
`; if(msg.file_type.startsWith('audio/')) return `
`; return `
📄 ${escapeHtml(msg.file_name)}
`; } function updateBubble(id) { const el = document.getElementById(`msg-${id}`); if(!el) return; const msg = messages[id]; el.className = `message-bubble ${msg.sender_id===currentUser.id?'message-self':'message-other'}`; if(msg.is_deleted) el.classList.add('deleted-bubble'); el.innerHTML = buildHTML(msg); } function renderAll() { messagesList.innerHTML = ''; messageIds.forEach(id => { if(messages[id]) { renderMessage(messages[id]); } }); } function scrollBottom() { document.getElementById('messages-container').scrollTop = document.getElementById('messages-container').scrollHeight; } // --- Actions --- window.replyTo = function(id){ replyToId=id; const m=messages[id]; if(m){ replyPreviewText.textContent=`Replying to ${m.display_name||m.username}: ${truncate(m.content,30)}`; replyPreview.classList.remove('hidden'); } }; cancelReply.addEventListener('click', ()=>{ replyToId=null; replyPreview.classList.add('hidden'); }); window.editMsg = function(id){ const newContent = prompt('Edit:', messages[id]?.content); if(newContent!==null && newContent.trim()!=='') ws.send(JSON.stringify({type:'edit_message', message_id:id, content:newContent})); }; window.deleteMsg = function(id){ if(confirm('Delete permanently?')) ws.send(JSON.stringify({type:'delete_message', message_id:id})); }; // --- Sending --- composeForm.addEventListener('submit', (e)=>{ e.preventDefault(); const content = messageInput.value.trim(); if(!content && !pendingFile) return; const msg = { type:'chat_message', content, reply_to_id:replyToId, client_id:Date.now().toString(36)+Math.random().toString(36).slice(2) }; if(pendingFile){ msg.file_path = pendingFile.file_path; msg.file_type = pendingFile.file_type; msg.file_name = pendingFile.file_name; pendingFile = null; } ws.send(JSON.stringify(msg)); messageInput.value = ''; clearReply(); }); function clearReply(){ replyToId=null; replyPreview.classList.add('hidden'); } // -- Typing -- messageInput.addEventListener('input', ()=>{ ws.send(JSON.stringify({type:'typing', is_typing:true})); clearTimeout(typingTimer); typingTimer = setTimeout(()=> ws.send(JSON.stringify({type:'typing', is_typing:false})), 2000); }); // -- File upload (chunked) -- attachBtn.addEventListener('click', ()=> fileInput.click()); fileInput.addEventListener('change', async (e)=>{ const file = e.target.files[0]; if(!file) return; const CHUNK = 5*1024*1024; const total = Math.ceil(file.size / CHUNK); const uploadId = Date.now().toString(36)+Math.random().toString(36).slice(2); uploadProgress.classList.remove('hidden'); uploadFilename.textContent = file.name; uploadBar.value=0; uploadBar.max=100; for(let i=0;i{ if(ws) ws.send(JSON.stringify({type:'load_messages', before_id: oldestMessageId, limit:50})); }); function loadMessages(){ if(ws && ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'load_messages', limit:50})); } // --- Settings --- settingsBtn.addEventListener('click', ()=>{ settingDisplayName.value = currentUser?.display_name || ''; if(currentUser?.avatar_path){ avatarPreview.src = `${API}/download/${currentUser.avatar_path}?token=${token}`; avatarPreview.style.display='block'; }else avatarPreview.style.display='none'; settingsModal.classList.remove('hidden'); }); closeSettingsBtn.addEventListener('click', ()=> settingsModal.classList.add('hidden')); saveSettingsBtn.addEventListener('click', async ()=>{ const newName = settingDisplayName.value.trim(); if(newName !== currentUser.display_name){ await fetch(`${API}/update_profile?display_name=${encodeURIComponent(newName)}`, {method:'POST', headers:{token}}); currentUser.display_name = newName; } const avatarFile = settingAvatar.files[0]; if(avatarFile){ const fd = new FormData(); fd.append('file', avatarFile); const res = await fetch(`${API}/upload_avatar`, {method:'POST', body:fd, headers:{token}}); if(res.ok){ const data = await res.json(); currentUser.avatar_path = data.avatar_path; avatarPreview.src = `${API}/download/${data.avatar_path}?token=${token}`; } } const theme = themeSelect.value; if(theme==='theme-custom'){ localStorage.setItem('customSelfColor', customSelfColor.value); localStorage.setItem('customOtherColor', customOtherColor.value); localStorage.setItem('customTextColor', customTextColor.value); } applyTheme(theme); settingsModal.classList.add('hidden'); }); themeSelect.addEventListener('change', (e)=>{ if(e.target.value==='theme-custom') customThemeSettings.classList.remove('hidden'); else customThemeSettings.classList.add('hidden'); }); logoutBtn.addEventListener('click', async ()=>{ if(ws) ws.close(); if(token) await fetch(`${API}/logout?token=${encodeURIComponent(token)}`, {method:'GET'}); localStorage.removeItem('token'); token=null; currentUser=null; messages={}; messageIds=[]; authScreen.classList.remove('hidden'); chatScreen.classList.add('hidden'); }); // --- Notification permission --- if(window.Notification && Notification.permission==='default') Notification.requestPermission(); // --- Utils --- function truncate(s,n){ return s.length>n ? s.slice(0,n-1)+'…' : s; } function escapeHtml(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; } function formatTime(ms){ const d = new Date(ms), now = new Date(); if(d.toDateString()===now.toDateString()) return `Today at ${d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`; return d.toLocaleDateString() +' '+ d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } // -- Init -- loadTheme(); (async ()=>{ if(token && await autoLogin()) showChat(); })(); })();