smodusermc commited on
Commit
cfd4888
·
verified ·
1 Parent(s): a581495

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +417 -147
static/script.js CHANGED
@@ -4,12 +4,14 @@
4
  let currentUser = null;
5
  let token = localStorage.getItem('token');
6
  let ws = null;
7
- let messages = {}; // id -> msg object
8
- let messageIds = []; // ordered
 
9
  let oldestMessageId = null;
10
  let replyToId = null;
11
  let typingTimer = null;
12
- let pendingFile = null; // {file_path, file_type, file_name}
 
13
 
14
  const API = window.location.origin;
15
 
@@ -70,6 +72,7 @@
70
  document.documentElement.style.setProperty('--bubble-text-other', txt);
71
  }
72
  }
 
73
  function loadTheme() {
74
  const saved = localStorage.getItem('theme') || 'theme-light';
75
  themeSelect.value = saved;
@@ -78,39 +81,91 @@
78
  }
79
 
80
  // --- Auth ---
81
- loginTab.addEventListener('click', ()=>{
82
- isLogin=true; loginTab.classList.add('active'); signupTab.classList.remove('active');
83
- displayNameGroup.style.display='none'; authSubmit.textContent='Login';
 
 
 
84
  });
85
- signupTab.addEventListener('click', ()=>{
86
- isLogin=false; signupTab.classList.add('active'); loginTab.classList.remove('active');
87
- displayNameGroup.style.display='block'; authSubmit.textContent='Sign Up';
 
 
 
 
88
  });
89
- authForm.addEventListener('submit', async (e)=>{
 
90
  e.preventDefault();
91
- const u = authUsername.value.trim(), p = authPassword.value, d = authDisplayName.value.trim();
92
- if(!u||!p){ authError.textContent='Fill all fields.'; return; }
93
- try{
94
- const url = isLogin ? `${API}/login?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}`
95
- : `${API}/signup?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}&display_name=${encodeURIComponent(d)}`;
96
- const res = await fetch(url, {method:'POST'});
97
- if(!res.ok) throw new Error((await res.json()).detail||'Auth failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  const data = await res.json();
99
  token = data.token;
100
- currentUser = { id: data.id, username: data.username, display_name: data.display_name, avatar_path: data.avatar_path };
 
 
 
 
 
 
101
  localStorage.setItem('token', token);
 
102
  showChat();
103
- }catch(err){ authError.textContent=err.message; }
 
 
104
  });
105
 
106
  async function autoLogin() {
107
- if(!token) return false;
108
- try{
109
- const res = await fetch(`${API}/me`, {headers:{token}});
110
- if(!res.ok){ token=null; localStorage.removeItem('token'); return false; }
 
 
 
 
 
 
 
 
 
 
 
111
  currentUser = await res.json();
112
  return true;
113
- }catch{ return false; }
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
  function showChat() {
@@ -120,59 +175,131 @@
120
  loadMessages();
121
  }
122
 
123
- // --- WebSocket ---
124
  function connectWS() {
125
- if(ws && ws.readyState===WebSocket.OPEN) return;
126
- const proto = location.protocol==='https:'?'wss:':'ws:';
127
- ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`);
128
- ws.onopen = ()=>{ console.log('WS open'); };
129
- ws.onmessage = (ev)=> handleWSMessage(JSON.parse(ev.data));
130
- ws.onclose = ()=> setTimeout(connectWS, 3000);
131
- ws.onerror = ()=> {};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  }
133
 
134
  function handleWSMessage(msg) {
135
- switch(msg.type){
136
- case 'online_users': onlineCountSpan.textContent = `(${msg.users.length} online)`; break;
137
- case 'user_online': break;
138
- case 'user_offline': break;
139
  case 'new_message':
140
- if(messages[msg.id]) return;
141
  messages[msg.id] = msg;
142
  messageIds.push(msg.id);
143
  renderMessage(msg);
144
  scrollBottom();
145
- if(document.visibilityState==='visible') ws.send(JSON.stringify({type:'mark_read', up_to_message_id:msg.id}));
 
 
 
 
 
 
 
 
 
146
  break;
147
  case 'messages_batch':
148
- if(msg.messages.length>0){
149
- for(const m of msg.messages){
150
- if(!messages[m.id]){ messages[m.id]=m; messageIds.unshift(m.id); }
 
 
 
151
  }
152
  renderAll();
153
  scrollBottom();
154
  }
155
- if(msg.messages.length<50) loadOlderBtn.classList.add('hidden');
156
- else loadOlderBtn.classList.remove('hidden');
157
  break;
158
  case 'typing_update':
159
- typingIndicator.textContent = msg.is_typing ? `${msg.username} is typing…` : ''; break;
 
160
  case 'edit_message':
161
- if(messages[msg.message_id]){
162
  messages[msg.message_id].content = msg.content;
163
  messages[msg.message_id].is_edited = true;
164
  updateBubble(msg.message_id);
165
  }
166
  break;
167
  case 'delete_message':
168
- if(messages[msg.message_id]){
169
  messages[msg.message_id].is_deleted = true;
170
  updateBubble(msg.message_id);
171
  }
172
  break;
173
  case 'read_receipt':
174
- if(messages[msg.message_id] && messages[msg.message_id].sender_id === currentUser.id){
175
- // Update status to "read"
176
  messages[msg.message_id].status = 'read';
177
  updateBubble(msg.message_id);
178
  }
@@ -180,173 +307,281 @@
180
  }
181
  }
182
 
183
- // --- Render ---
184
  function renderMessage(msg) {
185
  const el = document.getElementById(`msg-${msg.id}`);
186
- if(el){
187
  updateBubble(msg.id);
188
  return;
189
  }
 
190
  const div = document.createElement('div');
191
  div.id = `msg-${msg.id}`;
192
- div.className = `message-bubble ${msg.sender_id===currentUser.id?'message-self':'message-other'}`;
193
  div.innerHTML = buildHTML(msg);
194
  messagesList.appendChild(div);
195
  }
 
196
  function buildHTML(msg) {
197
- if(msg.is_deleted) return `<em>This message was deleted</em>`;
 
198
  let html = '';
199
- if(msg.reply_to_id && messages[msg.reply_to_id]){
200
  const reply = messages[msg.reply_to_id];
201
- html += `<div class="reply-indicator">↩ ${reply.display_name||reply.username}: ${truncate(reply.content,40)}</div>`;
202
  }
203
- html += `<div class="message-info"><span>${msg.display_name||msg.username}</span><span class="timestamp">${formatTime(msg.timestamp_ms)}</span></div>`;
 
 
 
 
204
  html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
205
- if(msg.is_edited) html += ` <em class="edited-label">[edited]</em>`;
206
- if(msg.file_path) html += renderAttachment(msg);
207
- if(msg.sender_id===currentUser.id && !msg.is_deleted){
208
- const statusIcon = msg.status==='read' ? '✓✓' : '✓';
209
- html += `<div class="read-status double-check">${statusIcon}</div>`;
 
 
 
210
  html += `<div class="message-actions">
211
  <button onclick="window.editMsg(${msg.id})">✏️</button>
212
  <button onclick="window.deleteMsg(${msg.id})">🗑️</button>
213
  <button onclick="window.replyTo(${msg.id})">↩</button>
214
  </div>`;
215
  }
 
216
  return html;
217
  }
 
218
  function renderAttachment(msg) {
219
  const url = `${API}/download/${msg.file_path}?token=${encodeURIComponent(token)}`;
220
- 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>`;
221
- if(msg.file_type.startsWith('video/')) return `<div class="attachment"><video controls><source src="${url}" type="${msg.file_type}"></video></div>`;
222
- if(msg.file_type.startsWith('audio/')) return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
 
 
 
 
 
 
 
223
  return `<div class="attachment"><div class="file-preview" onclick="window.open('${url}')">📄 ${escapeHtml(msg.file_name)}</div></div>`;
224
  }
 
225
  function updateBubble(id) {
226
  const el = document.getElementById(`msg-${id}`);
227
- if(!el) return;
228
  const msg = messages[id];
229
- el.className = `message-bubble ${msg.sender_id===currentUser.id?'message-self':'message-other'}`;
230
- if(msg.is_deleted) el.classList.add('deleted-bubble');
 
 
231
  el.innerHTML = buildHTML(msg);
232
  }
 
233
  function renderAll() {
234
  messagesList.innerHTML = '';
235
- messageIds.forEach(id => { if(messages[id]) { renderMessage(messages[id]); } });
 
 
236
  }
 
237
  function scrollBottom() {
238
- document.getElementById('messages-container').scrollTop = document.getElementById('messages-container').scrollHeight;
 
239
  }
240
 
241
- // --- Actions ---
242
- 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'); } };
243
- cancelReply.addEventListener('click', ()=>{ replyToId=null; replyPreview.classList.add('hidden'); });
244
- window.editMsg = function(id){
245
- const newContent = prompt('Edit:', messages[id]?.content);
246
- if(newContent!==null && newContent.trim()!=='') ws.send(JSON.stringify({type:'edit_message', message_id:id, content:newContent}));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  };
248
- window.deleteMsg = function(id){
249
- if(confirm('Delete permanently?')) ws.send(JSON.stringify({type:'delete_message', message_id:id}));
 
 
 
250
  };
251
 
252
- // --- Sending ---
253
- composeForm.addEventListener('submit', (e)=>{
254
  e.preventDefault();
 
 
 
 
 
255
  const content = messageInput.value.trim();
256
- if(!content && !pendingFile) return;
257
- const msg = { type:'chat_message', content, reply_to_id:replyToId, client_id:Date.now().toString(36)+Math.random().toString(36).slice(2) };
258
- if(pendingFile){
 
 
 
 
 
 
 
259
  msg.file_path = pendingFile.file_path;
260
  msg.file_type = pendingFile.file_type;
261
  msg.file_name = pendingFile.file_name;
262
  pendingFile = null;
263
  }
 
264
  ws.send(JSON.stringify(msg));
265
  messageInput.value = '';
266
  clearReply();
267
  });
268
- function clearReply(){ replyToId=null; replyPreview.classList.add('hidden'); }
269
 
270
- // -- Typing --
271
- messageInput.addEventListener('input', ()=>{
272
- ws.send(JSON.stringify({type:'typing', is_typing:true}));
 
 
 
 
 
 
273
  clearTimeout(typingTimer);
274
- typingTimer = setTimeout(()=> ws.send(JSON.stringify({type:'typing', is_typing:false})), 2000);
 
 
275
  });
276
 
277
- // -- File upload (chunked) --
278
- attachBtn.addEventListener('click', ()=> fileInput.click());
279
- fileInput.addEventListener('change', async (e)=>{
 
280
  const file = e.target.files[0];
281
- if(!file) return;
282
- const CHUNK = 5*1024*1024;
 
283
  const total = Math.ceil(file.size / CHUNK);
284
- const uploadId = Date.now().toString(36)+Math.random().toString(36).slice(2);
 
285
  uploadProgress.classList.remove('hidden');
286
  uploadFilename.textContent = file.name;
287
- uploadBar.value=0; uploadBar.max=100;
288
- for(let i=0;i<total;i++){
289
- const start = i*CHUNK, end = Math.min(start+CHUNK, file.size);
290
- const blob = file.slice(start,end);
291
- const fd = new FormData(); fd.append('file', blob);
292
- 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}`;
293
- try{
294
- const res = await fetch(url, {method:'POST', body:fd, headers:{token}});
295
- if(!res.ok) throw new Error('Upload failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  const data = await res.json();
297
- if(data.status==='complete'){
298
- pendingFile = {file_path:data.file_path, file_type:data.file_type, file_name:data.file_name};
299
- alert('File ready! Add a message and send.');
 
 
 
 
300
  uploadProgress.classList.add('hidden');
301
  break;
302
  }
303
- uploadBar.value = ((i+1)/total)*100;
304
- }catch(err){
305
- alert('Upload error: '+err.message);
 
306
  uploadProgress.classList.add('hidden');
307
  break;
308
  }
309
  }
310
- fileInput.value='';
 
311
  });
312
 
313
- // -- Load older --
314
- loadOlderBtn.addEventListener('click', ()=>{
315
- if(ws) ws.send(JSON.stringify({type:'load_messages', before_id: oldestMessageId, limit:50}));
 
 
 
316
  });
317
 
318
- function loadMessages(){
319
- if(ws && ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'load_messages', limit:50}));
 
 
320
  }
321
 
322
  // --- Settings ---
323
- settingsBtn.addEventListener('click', ()=>{
324
  settingDisplayName.value = currentUser?.display_name || '';
325
- if(currentUser?.avatar_path){
326
- avatarPreview.src = `${API}/download/${currentUser.avatar_path}?token=${token}`;
327
- avatarPreview.style.display='block';
328
- }else avatarPreview.style.display='none';
 
 
329
  settingsModal.classList.remove('hidden');
330
  });
331
- closeSettingsBtn.addEventListener('click', ()=> settingsModal.classList.add('hidden'));
332
- saveSettingsBtn.addEventListener('click', async ()=>{
 
 
333
  const newName = settingDisplayName.value.trim();
334
- if(newName !== currentUser.display_name){
335
- await fetch(`${API}/update_profile?display_name=${encodeURIComponent(newName)}`, {method:'POST', headers:{token}});
 
 
 
336
  currentUser.display_name = newName;
337
  }
 
338
  const avatarFile = settingAvatar.files[0];
339
- if(avatarFile){
340
- const fd = new FormData(); fd.append('file', avatarFile);
341
- const res = await fetch(`${API}/upload_avatar`, {method:'POST', body:fd, headers:{token}});
342
- if(res.ok){
 
 
 
 
 
 
 
343
  const data = await res.json();
344
  currentUser.avatar_path = data.avatar_path;
345
- avatarPreview.src = `${API}/download/${data.avatar_path}?token=${token}`;
 
346
  }
347
  }
 
348
  const theme = themeSelect.value;
349
- if(theme==='theme-custom'){
350
  localStorage.setItem('customSelfColor', customSelfColor.value);
351
  localStorage.setItem('customOtherColor', customOtherColor.value);
352
  localStorage.setItem('customTextColor', customTextColor.value);
@@ -354,34 +589,69 @@
354
  applyTheme(theme);
355
  settingsModal.classList.add('hidden');
356
  });
357
- themeSelect.addEventListener('change', (e)=>{
358
- if(e.target.value==='theme-custom') customThemeSettings.classList.remove('hidden');
359
- else customThemeSettings.classList.add('hidden');
360
  });
361
 
362
- logoutBtn.addEventListener('click', async ()=>{
363
- if(ws) ws.close();
364
- if(token) await fetch(`${API}/logout?token=${encodeURIComponent(token)}`, {method:'GET'});
 
 
 
 
 
 
 
 
365
  localStorage.removeItem('token');
366
- token=null; currentUser=null; messages={}; messageIds=[];
367
- authScreen.classList.remove('hidden'); chatScreen.classList.add('hidden');
 
 
 
 
 
 
 
368
  });
369
 
370
  // --- Notification permission ---
371
- if(window.Notification && Notification.permission==='default') Notification.requestPermission();
 
 
 
 
 
 
 
 
372
 
373
- // --- Utils ---
374
- function truncate(s,n){ return s.length>n ? s.slice(0,n-1)+'…' : s; }
375
- function escapeHtml(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
376
- function formatTime(ms){
377
- const d = new Date(ms), now = new Date();
378
- if(d.toDateString()===now.toDateString()) return `Today at ${d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;
379
- return d.toLocaleDateString() +' '+ d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
380
  }
381
 
382
- // -- Init --
 
 
 
 
 
 
 
 
 
383
  loadTheme();
384
- (async ()=>{
385
- if(token && await autoLogin()) showChat();
 
 
 
 
 
 
386
  })();
387
  })();
 
4
  let currentUser = null;
5
  let token = localStorage.getItem('token');
6
  let ws = null;
7
+ let wsReconnectTimer = null;
8
+ let messages = {};
9
+ let messageIds = [];
10
  let oldestMessageId = null;
11
  let replyToId = null;
12
  let typingTimer = null;
13
+ let pendingFile = null;
14
+ let isConnected = false;
15
 
16
  const API = window.location.origin;
17
 
 
72
  document.documentElement.style.setProperty('--bubble-text-other', txt);
73
  }
74
  }
75
+
76
  function loadTheme() {
77
  const saved = localStorage.getItem('theme') || 'theme-light';
78
  themeSelect.value = saved;
 
81
  }
82
 
83
  // --- Auth ---
84
+ loginTab.addEventListener('click', () => {
85
+ isLogin = true;
86
+ loginTab.classList.add('active');
87
+ signupTab.classList.remove('active');
88
+ displayNameGroup.style.display = 'none';
89
+ authSubmit.textContent = 'Login';
90
  });
91
+
92
+ signupTab.addEventListener('click', () => {
93
+ isLogin = false;
94
+ signupTab.classList.add('active');
95
+ loginTab.classList.remove('active');
96
+ displayNameGroup.style.display = 'block';
97
+ authSubmit.textContent = 'Sign Up';
98
  });
99
+
100
+ authForm.addEventListener('submit', async (e) => {
101
  e.preventDefault();
102
+ const u = authUsername.value.trim();
103
+ const p = authPassword.value;
104
+ const d = authDisplayName.value.trim();
105
+
106
+ if (!u || !p) {
107
+ authError.textContent = 'Please fill all fields.';
108
+ return;
109
+ }
110
+
111
+ try {
112
+ const url = isLogin
113
+ ? `${API}/login?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}`
114
+ : `${API}/signup?username=${encodeURIComponent(u)}&password=${encodeURIComponent(p)}&display_name=${encodeURIComponent(d)}`;
115
+
116
+ const res = await fetch(url, { method: 'POST' });
117
+
118
+ if (!res.ok) {
119
+ const err = await res.json();
120
+ throw new Error(err.detail || 'Authentication failed');
121
+ }
122
+
123
  const data = await res.json();
124
  token = data.token;
125
+ currentUser = {
126
+ id: data.id,
127
+ username: data.username,
128
+ display_name: data.display_name,
129
+ avatar_path: data.avatar_path
130
+ };
131
+
132
  localStorage.setItem('token', token);
133
+ authError.textContent = '';
134
  showChat();
135
+ } catch (err) {
136
+ authError.textContent = err.message;
137
+ }
138
  });
139
 
140
  async function autoLogin() {
141
+ if (!token) {
142
+ return false;
143
+ }
144
+
145
+ try {
146
+ const res = await fetch(`${API}/me`, {
147
+ headers: { 'token': token }
148
+ });
149
+
150
+ if (!res.ok) {
151
+ token = null;
152
+ localStorage.removeItem('token');
153
+ return false;
154
+ }
155
+
156
  currentUser = await res.json();
157
  return true;
158
+ } catch (e) {
159
+ token = null;
160
+ localStorage.removeItem('token');
161
+ return false;
162
+ }
163
+ }
164
+
165
+ function showAuth() {
166
+ authScreen.classList.remove('hidden');
167
+ chatScreen.classList.add('hidden');
168
+ disconnectWS();
169
  }
170
 
171
  function showChat() {
 
175
  loadMessages();
176
  }
177
 
178
+ // --- WebSocket (fixed – never send null as token) ---
179
  function connectWS() {
180
+ // Don't connect if we don't have a valid token
181
+ if (!token || token === 'null' || token === 'undefined') {
182
+ console.warn('No valid token – not connecting WebSocket');
183
+ return;
184
+ }
185
+
186
+ // Don't create duplicate connections
187
+ if (ws && ws.readyState === WebSocket.OPEN) {
188
+ return;
189
+ }
190
+
191
+ // Close any existing connection
192
+ if (ws) {
193
+ ws.close();
194
+ ws = null;
195
+ }
196
+
197
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
198
+ const wsUrl = `${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`;
199
+
200
+ console.log('Connecting WebSocket...');
201
+ ws = new WebSocket(wsUrl);
202
+
203
+ ws.onopen = () => {
204
+ console.log('WebSocket connected');
205
+ isConnected = true;
206
+ // Clear any reconnect timer
207
+ if (wsReconnectTimer) {
208
+ clearTimeout(wsReconnectTimer);
209
+ wsReconnectTimer = null;
210
+ }
211
+ // Request initial message load
212
+ ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
213
+ };
214
+
215
+ ws.onmessage = (ev) => {
216
+ try {
217
+ handleWSMessage(JSON.parse(ev.data));
218
+ } catch (e) {
219
+ console.error('Error parsing WS message:', e);
220
+ }
221
+ };
222
+
223
+ ws.onclose = () => {
224
+ console.log('WebSocket closed');
225
+ isConnected = false;
226
+ ws = null;
227
+ // Only reconnect if we have a valid token
228
+ if (token && token !== 'null' && token !== 'undefined' && currentUser) {
229
+ wsReconnectTimer = setTimeout(connectWS, 3000);
230
+ }
231
+ };
232
+
233
+ ws.onerror = (err) => {
234
+ console.error('WebSocket error:', err);
235
+ };
236
+ }
237
+
238
+ function disconnectWS() {
239
+ if (wsReconnectTimer) {
240
+ clearTimeout(wsReconnectTimer);
241
+ wsReconnectTimer = null;
242
+ }
243
+ if (ws) {
244
+ ws.close();
245
+ ws = null;
246
+ }
247
+ isConnected = false;
248
  }
249
 
250
  function handleWSMessage(msg) {
251
+ switch (msg.type) {
252
+ case 'online_users':
253
+ onlineCountSpan.textContent = `(${msg.users.length} online)`;
254
+ break;
255
  case 'new_message':
256
+ if (messages[msg.id]) return;
257
  messages[msg.id] = msg;
258
  messageIds.push(msg.id);
259
  renderMessage(msg);
260
  scrollBottom();
261
+ // Mark as read if visible
262
+ if (document.visibilityState === 'visible' && isConnected) {
263
+ ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: msg.id }));
264
+ }
265
+ // Browser notification
266
+ if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
267
+ new Notification(`New message from ${msg.display_name || msg.username}`, {
268
+ body: msg.content || 'Sent a file'
269
+ });
270
+ }
271
  break;
272
  case 'messages_batch':
273
+ if (msg.messages.length > 0) {
274
+ for (const m of msg.messages) {
275
+ if (!messages[m.id]) {
276
+ messages[m.id] = m;
277
+ messageIds.unshift(m.id);
278
+ }
279
  }
280
  renderAll();
281
  scrollBottom();
282
  }
283
+ loadOlderBtn.classList.toggle('hidden', msg.messages.length < 50);
 
284
  break;
285
  case 'typing_update':
286
+ typingIndicator.textContent = msg.is_typing ? `${msg.username} is typing...` : '';
287
+ break;
288
  case 'edit_message':
289
+ if (messages[msg.message_id]) {
290
  messages[msg.message_id].content = msg.content;
291
  messages[msg.message_id].is_edited = true;
292
  updateBubble(msg.message_id);
293
  }
294
  break;
295
  case 'delete_message':
296
+ if (messages[msg.message_id]) {
297
  messages[msg.message_id].is_deleted = true;
298
  updateBubble(msg.message_id);
299
  }
300
  break;
301
  case 'read_receipt':
302
+ if (messages[msg.message_id] && messages[msg.message_id].sender_id === currentUser?.id) {
 
303
  messages[msg.message_id].status = 'read';
304
  updateBubble(msg.message_id);
305
  }
 
307
  }
308
  }
309
 
310
+ // --- Message rendering (same as before) ---
311
  function renderMessage(msg) {
312
  const el = document.getElementById(`msg-${msg.id}`);
313
+ if (el) {
314
  updateBubble(msg.id);
315
  return;
316
  }
317
+
318
  const div = document.createElement('div');
319
  div.id = `msg-${msg.id}`;
320
+ div.className = `message-bubble ${msg.sender_id === currentUser?.id ? 'message-self' : 'message-other'}`;
321
  div.innerHTML = buildHTML(msg);
322
  messagesList.appendChild(div);
323
  }
324
+
325
  function buildHTML(msg) {
326
+ if (msg.is_deleted) return '<em>This message was deleted</em>';
327
+
328
  let html = '';
329
+ if (msg.reply_to_id && messages[msg.reply_to_id]) {
330
  const reply = messages[msg.reply_to_id];
331
+ html += `<div class="reply-indicator">↩ ${reply.display_name || reply.username}: ${truncate(reply.content, 40)}</div>`;
332
  }
333
+
334
+ html += `<div class="message-info">
335
+ <span>${msg.display_name || msg.username}</span>
336
+ <span class="timestamp">${formatTime(msg.timestamp_ms)}</span>
337
+ </div>`;
338
  html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
339
+
340
+ if (msg.is_edited) html += ` <em class="edited-label">[edited]</em>`;
341
+ if (msg.file_path) html += renderAttachment(msg);
342
+
343
+ // Status indicators for own messages
344
+ if (msg.sender_id === currentUser?.id && !msg.is_deleted) {
345
+ const statusIcon = msg.status === 'read' ? '✓✓' : '✓';
346
+ html += `<div class="message-status">${statusIcon}</div>`;
347
  html += `<div class="message-actions">
348
  <button onclick="window.editMsg(${msg.id})">✏️</button>
349
  <button onclick="window.deleteMsg(${msg.id})">🗑️</button>
350
  <button onclick="window.replyTo(${msg.id})">↩</button>
351
  </div>`;
352
  }
353
+
354
  return html;
355
  }
356
+
357
  function renderAttachment(msg) {
358
  const url = `${API}/download/${msg.file_path}?token=${encodeURIComponent(token)}`;
359
+
360
+ if (msg.file_type.startsWith('image/')) {
361
+ return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" onerror="this.parentElement.innerHTML='<em>Failed to decode</em>'"></div>`;
362
+ }
363
+ if (msg.file_type.startsWith('video/')) {
364
+ return `<div class="attachment"><video controls><source src="${url}" type="${msg.file_type}">Your browser doesn't support video</video></div>`;
365
+ }
366
+ if (msg.file_type.startsWith('audio/')) {
367
+ return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
368
+ }
369
  return `<div class="attachment"><div class="file-preview" onclick="window.open('${url}')">📄 ${escapeHtml(msg.file_name)}</div></div>`;
370
  }
371
+
372
  function updateBubble(id) {
373
  const el = document.getElementById(`msg-${id}`);
374
+ if (!el) return;
375
  const msg = messages[id];
376
+ if (!msg) return;
377
+
378
+ el.className = `message-bubble ${msg.sender_id === currentUser?.id ? 'message-self' : 'message-other'}`;
379
+ if (msg.is_deleted) el.classList.add('deleted-bubble');
380
  el.innerHTML = buildHTML(msg);
381
  }
382
+
383
  function renderAll() {
384
  messagesList.innerHTML = '';
385
+ messageIds.forEach(id => {
386
+ if (messages[id]) renderMessage(messages[id]);
387
+ });
388
  }
389
+
390
  function scrollBottom() {
391
+ const container = document.getElementById('messages-container');
392
+ container.scrollTop = container.scrollHeight;
393
  }
394
 
395
+ // --- Message actions ---
396
+ window.replyTo = function(id) {
397
+ replyToId = id;
398
+ const m = messages[id];
399
+ if (m) {
400
+ replyPreviewText.textContent = `Replying to ${m.display_name || m.username}: ${truncate(m.content, 30)}`;
401
+ replyPreview.classList.remove('hidden');
402
+ }
403
+ };
404
+
405
+ cancelReply.addEventListener('click', () => {
406
+ replyToId = null;
407
+ replyPreview.classList.add('hidden');
408
+ });
409
+
410
+ window.editMsg = function(id) {
411
+ const newContent = prompt('Edit message:', messages[id]?.content || '');
412
+ if (newContent !== null && newContent.trim() !== '' && isConnected) {
413
+ ws.send(JSON.stringify({ type: 'edit_message', message_id: id, content: newContent }));
414
+ }
415
  };
416
+
417
+ window.deleteMsg = function(id) {
418
+ if (confirm('Delete this message permanently?') && isConnected) {
419
+ ws.send(JSON.stringify({ type: 'delete_message', message_id: id }));
420
+ }
421
  };
422
 
423
+ // --- Sending messages ---
424
+ composeForm.addEventListener('submit', (e) => {
425
  e.preventDefault();
426
+ if (!isConnected) {
427
+ console.warn('Not connected – cannot send');
428
+ return;
429
+ }
430
+
431
  const content = messageInput.value.trim();
432
+ if (!content && !pendingFile) return;
433
+
434
+ const msg = {
435
+ type: 'chat_message',
436
+ content: content || '',
437
+ reply_to_id: replyToId,
438
+ client_id: Date.now().toString(36) + Math.random().toString(36).slice(2)
439
+ };
440
+
441
+ if (pendingFile) {
442
  msg.file_path = pendingFile.file_path;
443
  msg.file_type = pendingFile.file_type;
444
  msg.file_name = pendingFile.file_name;
445
  pendingFile = null;
446
  }
447
+
448
  ws.send(JSON.stringify(msg));
449
  messageInput.value = '';
450
  clearReply();
451
  });
 
452
 
453
+ function clearReply() {
454
+ replyToId = null;
455
+ replyPreview.classList.add('hidden');
456
+ }
457
+
458
+ // --- Typing indicator ---
459
+ messageInput.addEventListener('input', () => {
460
+ if (!isConnected) return;
461
+ ws.send(JSON.stringify({ type: 'typing', is_typing: true }));
462
  clearTimeout(typingTimer);
463
+ typingTimer = setTimeout(() => {
464
+ if (isConnected) ws.send(JSON.stringify({ type: 'typing', is_typing: false }));
465
+ }, 2000);
466
  });
467
 
468
+ // --- File upload ---
469
+ attachBtn.addEventListener('click', () => fileInput.click());
470
+
471
+ fileInput.addEventListener('change', async (e) => {
472
  const file = e.target.files[0];
473
+ if (!file) return;
474
+
475
+ const CHUNK = 5 * 1024 * 1024; // 5MB
476
  const total = Math.ceil(file.size / CHUNK);
477
+ const uploadId = Date.now().toString(36) + Math.random().toString(36).slice(2);
478
+
479
  uploadProgress.classList.remove('hidden');
480
  uploadFilename.textContent = file.name;
481
+ uploadBar.value = 0;
482
+ uploadBar.max = 100;
483
+
484
+ for (let i = 0; i < total; i++) {
485
+ const start = i * CHUNK;
486
+ const end = Math.min(start + CHUNK, file.size);
487
+ const blob = file.slice(start, end);
488
+
489
+ const fd = new FormData();
490
+ fd.append('file', blob);
491
+
492
+ 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}`;
493
+
494
+ try {
495
+ const res = await fetch(url, {
496
+ method: 'POST',
497
+ body: fd,
498
+ headers: { 'token': token }
499
+ });
500
+
501
+ if (!res.ok) throw new Error('Upload failed');
502
+
503
  const data = await res.json();
504
+
505
+ if (data.status === 'complete') {
506
+ pendingFile = {
507
+ file_path: data.file_path,
508
+ file_type: data.file_type,
509
+ file_name: data.file_name
510
+ };
511
  uploadProgress.classList.add('hidden');
512
  break;
513
  }
514
+
515
+ uploadBar.value = ((i + 1) / total) * 100;
516
+ } catch (err) {
517
+ alert('Upload error: ' + err.message);
518
  uploadProgress.classList.add('hidden');
519
  break;
520
  }
521
  }
522
+
523
+ fileInput.value = '';
524
  });
525
 
526
+ // --- Load older messages ---
527
+ loadOlderBtn.addEventListener('click', () => {
528
+ if (isConnected && messageIds.length > 0) {
529
+ const oldestId = messageIds[0];
530
+ ws.send(JSON.stringify({ type: 'load_messages', before_id: oldestId, limit: 50 }));
531
+ }
532
  });
533
 
534
+ function loadMessages() {
535
+ if (isConnected) {
536
+ ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
537
+ }
538
  }
539
 
540
  // --- Settings ---
541
+ settingsBtn.addEventListener('click', () => {
542
  settingDisplayName.value = currentUser?.display_name || '';
543
+ if (currentUser?.avatar_path) {
544
+ avatarPreview.src = `${API}/download/${currentUser.avatar_path}?token=${encodeURIComponent(token)}`;
545
+ avatarPreview.style.display = 'block';
546
+ } else {
547
+ avatarPreview.style.display = 'none';
548
+ }
549
  settingsModal.classList.remove('hidden');
550
  });
551
+
552
+ closeSettingsBtn.addEventListener('click', () => settingsModal.classList.add('hidden'));
553
+
554
+ saveSettingsBtn.addEventListener('click', async () => {
555
  const newName = settingDisplayName.value.trim();
556
+ if (newName && newName !== currentUser?.display_name) {
557
+ await fetch(`${API}/update_profile?display_name=${encodeURIComponent(newName)}`, {
558
+ method: 'POST',
559
+ headers: { 'token': token }
560
+ });
561
  currentUser.display_name = newName;
562
  }
563
+
564
  const avatarFile = settingAvatar.files[0];
565
+ if (avatarFile) {
566
+ const fd = new FormData();
567
+ fd.append('file', avatarFile);
568
+
569
+ const res = await fetch(`${API}/upload_avatar`, {
570
+ method: 'POST',
571
+ body: fd,
572
+ headers: { 'token': token }
573
+ });
574
+
575
+ if (res.ok) {
576
  const data = await res.json();
577
  currentUser.avatar_path = data.avatar_path;
578
+ avatarPreview.src = `${API}/download/${data.avatar_path}?token=${encodeURIComponent(token)}`;
579
+ avatarPreview.style.display = 'block';
580
  }
581
  }
582
+
583
  const theme = themeSelect.value;
584
+ if (theme === 'theme-custom') {
585
  localStorage.setItem('customSelfColor', customSelfColor.value);
586
  localStorage.setItem('customOtherColor', customOtherColor.value);
587
  localStorage.setItem('customTextColor', customTextColor.value);
 
589
  applyTheme(theme);
590
  settingsModal.classList.add('hidden');
591
  });
592
+
593
+ themeSelect.addEventListener('change', (e) => {
594
+ customThemeSettings.classList.toggle('hidden', e.target.value !== 'theme-custom');
595
  });
596
 
597
+ // --- Logout ---
598
+ logoutBtn.addEventListener('click', async () => {
599
+ if (token) {
600
+ try {
601
+ await fetch(`${API}/logout?token=${encodeURIComponent(token)}`, { method: 'GET' });
602
+ } catch (e) {
603
+ console.error('Logout error:', e);
604
+ }
605
+ }
606
+
607
+ disconnectWS();
608
  localStorage.removeItem('token');
609
+ token = null;
610
+ currentUser = null;
611
+ messages = {};
612
+ messageIds = [];
613
+ messagesList.innerHTML = '';
614
+ typingIndicator.textContent = '';
615
+ onlineCountSpan.textContent = '0';
616
+
617
+ showAuth();
618
  });
619
 
620
  // --- Notification permission ---
621
+ if (window.Notification && Notification.permission === 'default') {
622
+ Notification.requestPermission();
623
+ }
624
+
625
+ // --- Utilities ---
626
+ function truncate(s, n) {
627
+ if (!s) return '';
628
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
629
+ }
630
 
631
+ function escapeHtml(text) {
632
+ const div = document.createElement('div');
633
+ div.textContent = text;
634
+ return div.innerHTML;
 
 
 
635
  }
636
 
637
+ function formatTime(ms) {
638
+ const d = new Date(ms);
639
+ const now = new Date();
640
+ if (d.toDateString() === now.toDateString()) {
641
+ return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
642
+ }
643
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
644
+ }
645
+
646
+ // --- Initialize ---
647
  loadTheme();
648
+
649
+ (async () => {
650
+ const loggedIn = await autoLogin();
651
+ if (loggedIn) {
652
+ showChat();
653
+ } else {
654
+ showAuth();
655
+ }
656
  })();
657
  })();