InfinityChat / static /script.js
smodusermc's picture
Create script.js
5876732 verified
Raw History Blame
19.1 kB
(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 `<em>This message was deleted</em>`;
let html = '';
if(msg.reply_to_id && messages[msg.reply_to_id]){
const reply = messages[msg.reply_to_id];
html += `<div class="reply-indicator">↩ ${reply.display_name||reply.username}: ${truncate(reply.content,40)}</div>`;
}
html += `<div class="message-info"><span>${msg.display_name||msg.username}</span><span class="timestamp">${formatTime(msg.timestamp_ms)}</span></div>`;
html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
if(msg.is_edited) html += ` <em class="edited-label">[edited]</em>`;
if(msg.file_path) html += renderAttachment(msg);
if(msg.sender_id===currentUser.id && !msg.is_deleted){
const statusIcon = msg.status==='read' ? '✓✓' : '✓';
html += `<div class="read-status double-check">${statusIcon}</div>`;
html += `<div class="message-actions">
<button onclick="window.editMsg(${msg.id})">✏️</button>
<button onclick="window.deleteMsg(${msg.id})">🗑️</button>
<button onclick="window.replyTo(${msg.id})">↩</button>
</div>`;
}
return html;
}
function renderAttachment(msg) {
const url = `${API}/download/${msg.file_path}?token=${encodeURIComponent(token)}`;
if(msg.file_type.startsWith('image/')) return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" onerror="this.parentElement.innerHTML='Failed to decode'"></div>`;
if(msg.file_type.startsWith('video/')) return `<div class="attachment"><video controls><source src="${url}" type="${msg.file_type}"></video></div>`;
if(msg.file_type.startsWith('audio/')) return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
return `<div class="attachment"><div class="file-preview" onclick="window.open('${url}')">📄 ${escapeHtml(msg.file_name)}</div></div>`;
}
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<total;i++){
const start = i*CHUNK, end = Math.min(start+CHUNK, file.size);
const blob = file.slice(start,end);
const fd = new FormData(); fd.append('file', blob);
const url = `${API}/upload_chunk?chunk_index=${i}&total_chunks=${total}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type||'application/octet-stream')}&upload_id=${uploadId}`;
try{
const res = await fetch(url, {method:'POST', body:fd, headers:{token}});
if(!res.ok) throw new Error('Upload failed');
const data = await res.json();
if(data.status==='complete'){
pendingFile = {file_path:data.file_path, file_type:data.file_type, file_name:data.file_name};
alert('File ready! Add a message and send.');
uploadProgress.classList.add('hidden');
break;
}
uploadBar.value = ((i+1)/total)*100;
}catch(err){
alert('Upload error: '+err.message);
uploadProgress.classList.add('hidden');
break;
}
}
fileInput.value='';
});
// -- Load older --
loadOlderBtn.addEventListener('click', ()=>{
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();
})();
})();