smodusermc commited on
Commit
656c709
·
verified ·
1 Parent(s): 32a9b6f

Update static/script.js

Browse files
Files changed (1) hide show
  1. static/script.js +785 -534
static/script.js CHANGED
@@ -1,631 +1,818 @@
 
 
 
 
 
1
  (function() {
2
  'use strict';
3
- // --- State ---
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
-
18
- // DOM elements
19
- const authScreen = document.getElementById('auth-screen');
20
- const chatScreen = document.getElementById('chat-screen');
21
- const authForm = document.getElementById('auth-form');
22
- const authUsername = document.getElementById('auth-username');
23
- const authPassword = document.getElementById('auth-password');
24
- const authDisplayName = document.getElementById('auth-display-name');
25
- const displayNameGroup = document.getElementById('display-name-group');
26
- const authSubmit = document.getElementById('auth-submit');
27
- const authError = document.getElementById('auth-error');
28
- const loginTab = document.getElementById('login-tab');
29
- const signupTab = document.getElementById('signup-tab');
30
- const messagesList = document.getElementById('messages-list');
31
- const composeForm = document.getElementById('compose-form');
32
- const messageInput = document.getElementById('message-input');
33
- const sendBtn = document.getElementById('send-btn');
34
- const attachBtn = document.getElementById('attach-btn');
35
- const fileInput = document.getElementById('file-input');
36
- const replyPreview = document.getElementById('reply-preview');
37
- const replyPreviewText = document.getElementById('reply-preview-text');
38
- const cancelReply = document.getElementById('cancel-reply');
39
- const typingIndicator = document.getElementById('typing-indicator');
40
- const onlineCountSpan = document.getElementById('online-count');
41
- const loadOlderBtn = document.getElementById('load-older-btn');
42
- const settingsBtn = document.getElementById('settings-btn');
43
- const logoutBtn = document.getElementById('logout-btn');
44
- const settingsModal = document.getElementById('settings-modal');
45
- const saveSettingsBtn = document.getElementById('save-settings');
46
- const closeSettingsBtn = document.getElementById('close-settings');
47
- const themeSelect = document.getElementById('theme-select');
48
- const customThemeSettings = document.getElementById('custom-theme-settings');
49
- const customSelfColor = document.getElementById('custom-self-color');
50
- const customOtherColor = document.getElementById('custom-other-color');
51
- const customTextColor = document.getElementById('custom-text-color');
52
- const settingDisplayName = document.getElementById('setting-display-name');
53
- const settingAvatar = document.getElementById('setting-avatar');
54
- const avatarPreview = document.getElementById('avatar-preview');
55
- const uploadProgress = document.getElementById('upload-progress');
56
- const uploadBar = document.getElementById('upload-bar');
57
- const uploadFilename = document.getElementById('upload-filename');
58
-
59
- let isLogin = true;
60
-
61
- // --- Theme ---
62
- function applyTheme(name) {
63
- document.body.className = name;
64
- localStorage.setItem('theme', name);
65
- if (name === 'theme-custom') {
66
- const self = localStorage.getItem('customSelfColor') || '#25D366';
67
- const other = localStorage.getItem('customOtherColor') || '#E5E5EA';
68
- const txt = localStorage.getItem('customTextColor') || '#000';
69
- document.documentElement.style.setProperty('--bubble-self', self);
70
- document.documentElement.style.setProperty('--bubble-other', other);
71
- document.documentElement.style.setProperty('--bubble-text-self', txt);
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;
79
- if (saved === 'theme-custom') customThemeSettings.classList.remove('hidden');
80
- applyTheme(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() {
172
- authScreen.classList.add('hidden');
173
- chatScreen.classList.remove('hidden');
174
- connectWS();
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
- }
 
 
 
 
306
  break;
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);
588
- }
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) {
@@ -635,23 +822,87 @@
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
  })();
 
1
+ // ===================================================
2
+ // InfinityChat - Secure Real-Time Messaging
3
+ // Vanilla JavaScript Frontend
4
+ // ===================================================
5
+
6
  (function() {
7
  'use strict';
8
+
9
+ // ====================================
10
+ // State Management
11
+ // ====================================
12
+ const State = {
13
+ token: localStorage.getItem('token'),
14
+ user: null,
15
+ ws: null,
16
+ wsState: 'disconnected', // disconnected | connecting | connected | reconnecting
17
+ messages: new Map(), // id -> message object
18
+ messageOrder: [], // ordered IDs for rendering
19
+ cursor: null, // oldest message ID currently loaded
20
+ hasMore: true,
21
+ replyTo: null, // message being replied to
22
+ pendingFile: null, // uploaded file awaiting send
23
+ typingUsers: new Map(),
24
+ onlineUsers: new Map(),
25
+ isAtBottom: true,
26
+ theme: localStorage.getItem('theme') || 'light',
27
+ customColors: {
28
+ self: localStorage.getItem('custom-self') || '#2563EB',
29
+ other: localStorage.getItem('custom-other') || '#F3F4F6',
30
+ text: localStorage.getItem('custom-text') || '#111827'
31
+ }
32
+ };
33
+
34
+ // ====================================
35
+ // DOM Elements Cache
36
+ // ====================================
37
+ const DOM = {
38
+ // Screens
39
+ authScreen: document.getElementById('auth-screen'),
40
+ chatScreen: document.getElementById('chat-screen'),
41
+
42
+ // Auth
43
+ loginForm: document.getElementById('login-form'),
44
+ signupForm: document.getElementById('signup-form'),
45
+ authTabs: document.querySelectorAll('.auth-tab'),
46
+ authError: document.getElementById('auth-error'),
47
+ authErrorText: document.getElementById('auth-error-text'),
48
+ authLoading: document.getElementById('auth-loading'),
49
+
50
+ // Chat Header
51
+ headerDisplayName: document.getElementById('header-display-name'),
52
+ headerAvatar: document.getElementById('header-avatar'),
53
+ headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
54
+ headerStatus: document.getElementById('header-status'),
55
+ onlineBadge: document.getElementById('online-badge'),
56
+
57
+ // Connection
58
+ connectionStatus: document.getElementById('connection-status'),
59
+ connectionText: document.getElementById('connection-text'),
60
+
61
+ // Messages
62
+ messagesList: document.getElementById('messages-list'),
63
+ messagesScroll: document.getElementById('messages-scroll'),
64
+ emptyState: document.getElementById('empty-state'),
65
+ loadOlderBtn: document.getElementById('load-older-btn'),
66
+ scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
67
+
68
+ // Typing
69
+ typingIndicator: document.getElementById('typing-indicator'),
70
+ typingText: document.getElementById('typing-text'),
71
+
72
+ // Reply
73
+ replyPreview: document.getElementById('reply-preview'),
74
+ replyUser: document.getElementById('reply-user'),
75
+ replyText: document.getElementById('reply-text'),
76
+
77
+ // Compose
78
+ composeForm: document.getElementById('compose-form'),
79
+ messageInput: document.getElementById('message-input'),
80
+ sendBtn: document.getElementById('send-btn'),
81
+ attachBtn: document.getElementById('attach-btn'),
82
+ emojiBtn: document.getElementById('emoji-btn'),
83
+ fileInput: document.getElementById('file-input'),
84
+
85
+ // Upload
86
+ uploadPreview: document.getElementById('upload-preview'),
87
+ uploadFilename: document.getElementById('upload-filename'),
88
+ cancelUpload: document.getElementById('cancel-upload'),
89
+ uploadProgress: document.getElementById('upload-progress'),
90
+ progressFill: document.getElementById('progress-fill'),
91
+ progressText: document.getElementById('progress-text'),
92
+
93
+ // Modals
94
+ settingsModal: document.getElementById('settings-modal'),
95
+ onlineModal: document.getElementById('online-modal'),
96
+ emojiPicker: document.getElementById('emoji-picker'),
97
+ emojiGrid: document.getElementById('emoji-grid'),
98
+
99
+ // Settings
100
+ settingsDisplayName: document.getElementById('settings-display-name'),
101
+ settingsUsername: document.getElementById('settings-username'),
102
+ settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
103
+ settingsAvatarImg: document.getElementById('settings-avatar-img'),
104
+ avatarInput: document.getElementById('avatar-input'),
105
+ themeOptions: document.querySelectorAll('.theme-option'),
106
+ customThemeSettings: document.getElementById('custom-theme-settings'),
107
+
108
+ // Toast
109
+ toastContainer: document.getElementById('toast-container')
110
+ };
111
+
112
+ const API_BASE = window.location.origin;
113
+
114
+ // ====================================
115
+ // Theme Management
116
+ // ====================================
117
+ function applyTheme(theme) {
118
+ document.documentElement.setAttribute('data-theme', theme);
119
+ State.theme = theme;
120
+ localStorage.setItem('theme', theme);
121
+
122
+ if (theme === 'custom') {
123
+ document.documentElement.style.setProperty('--bubble-self', State.customColors.self);
124
+ document.documentElement.style.setProperty('--bubble-other', State.customColors.other);
125
+ document.documentElement.style.setProperty('--bubble-other-text', State.customColors.text);
126
+ document.documentElement.style.setProperty('--bubble-self-text', isLightColor(State.customColors.self) ? '#111827' : '#FFFFFF');
127
+ }
128
+ }
129
+
130
+ function isLightColor(hex) {
131
+ const r = parseInt(hex.slice(1,3), 16);
132
+ const g = parseInt(hex.slice(3,5), 16);
133
+ const b = parseInt(hex.slice(5,7), 16);
134
+ return (0.299 * r + 0.587 * g + 0.114 * b) > 150;
135
+ }
136
+
137
+ applyTheme(State.theme);
138
+
139
+ // ====================================
140
+ // Toast Notifications
141
+ // ====================================
142
+ function showToast(message, type = 'info') {
143
+ const toast = document.createElement('div');
144
+ toast.className = `toast toast-${type}`;
145
+ let icon = 'info-circle';
146
+ if (type === 'success') icon = 'check-circle';
147
+ if (type === 'error') icon = 'exclamation-circle';
148
+ if (type === 'warning') icon = 'exclamation-triangle';
149
+ toast.innerHTML = `<i class="fas fa-${icon}"></i><span>${message}</span>`;
150
+ DOM.toastContainer.appendChild(toast);
151
+ setTimeout(() => toast.remove(), 4000);
152
+ }
153
+
154
+ // ====================================
155
+ // Authentication
156
+ // ====================================
157
+ DOM.authTabs.forEach(tab => {
158
+ tab.addEventListener('click', () => {
159
+ const target = tab.dataset.tab;
160
+ DOM.authTabs.forEach(t => t.classList.remove('active'));
161
+ tab.classList.add('active');
162
+ DOM.loginForm.classList.toggle('active', target === 'login');
163
+ DOM.signupForm.classList.toggle('active', target === 'signup');
164
+ DOM.authError.style.display = 'none';
165
+ });
166
  });
167
+
168
+ DOM.loginForm.addEventListener('submit', async (e) => {
169
+ e.preventDefault();
170
+ const username = document.getElementById('login-username').value.trim();
171
+ const password = document.getElementById('login-password').value;
172
+ await login(username, password);
 
173
  });
174
+
175
+ DOM.signupForm.addEventListener('submit', async (e) => {
176
  e.preventDefault();
177
+ const username = document.getElementById('signup-username').value.trim();
178
+ const display = document.getElementById('signup-display').value.trim() || username;
179
+ const password = document.getElementById('signup-password').value;
180
+ await signup(username, password, display);
181
+ });
182
+
183
+ async function login(username, password) {
184
+ showAuthLoading();
 
185
  try {
186
+ const res = await fetch(`${API_BASE}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
 
 
 
 
 
 
 
 
 
 
187
  const data = await res.json();
188
+ if (!res.ok) throw new Error(data.detail || 'Login failed');
189
+ handleAuthSuccess(data);
 
 
 
 
 
 
 
 
 
190
  } catch (err) {
191
+ showAuthError(err.message);
192
+ } finally {
193
+ hideAuthLoading();
194
  }
195
+ }
196
 
197
+ async function signup(username, password, display) {
198
+ showAuthLoading();
 
 
 
199
  try {
200
+ const res = await fetch(`${API_BASE}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' });
201
+ const data = await res.json();
202
+ if (!res.ok) throw new Error(data.detail || 'Signup failed');
203
+ handleAuthSuccess(data);
204
+ } catch (err) {
205
+ showAuthError(err.message);
206
+ } finally {
207
+ hideAuthLoading();
 
 
 
 
 
 
 
 
208
  }
209
  }
210
 
211
+ function handleAuthSuccess(data) {
212
+ State.token = data.token;
213
+ State.user = data.user;
214
+ localStorage.setItem('token', State.token);
215
+ showChat();
216
+ connectWebSocket();
217
  }
218
 
219
+ function showAuthLoading() {
220
+ DOM.authLoading.classList.remove('hidden');
221
+ DOM.authError.style.display = 'none';
 
 
222
  }
223
 
224
+ function hideAuthLoading() {
225
+ DOM.authLoading.classList.add('hidden');
226
+ }
227
+
228
+ function showAuthError(msg) {
229
+ DOM.authErrorText.textContent = msg;
230
+ DOM.authError.style.display = 'flex';
231
+ }
232
+
233
+ // ====================================
234
+ // WebSocket Connection
235
+ // ====================================
236
+ function connectWebSocket() {
237
+ if (State.ws && State.ws.readyState === WebSocket.OPEN) return;
238
+ if (!State.token) return;
239
 
240
+ State.wsState = 'connecting';
241
+ updateConnectionUI();
 
 
 
 
 
 
 
 
242
 
243
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
244
+ const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`;
245
 
246
+ State.ws = new WebSocket(url);
 
247
 
248
+ State.ws.onopen = () => {
249
+ State.wsState = 'connected';
250
+ updateConnectionUI();
251
+ loadMessages();
252
+ requestOnlineUsers();
 
 
 
 
 
253
  };
254
 
255
+ State.ws.onmessage = (event) => handleWSMessage(JSON.parse(event.data));
 
 
 
 
 
 
256
 
257
+ State.ws.onclose = () => {
258
+ if (State.wsState === 'connected') {
259
+ State.wsState = 'reconnecting';
260
+ updateConnectionUI();
261
+ setTimeout(connectWebSocket, 3000);
262
+ } else {
263
+ State.wsState = 'disconnected';
264
+ updateConnectionUI();
265
  }
266
  };
267
 
268
+ State.ws.onerror = () => {
269
+ State.wsState = 'disconnected';
270
+ updateConnectionUI();
271
  };
272
  }
273
 
274
+ function updateConnectionUI() {
275
+ const status = DOM.connectionStatus;
276
+ const text = DOM.connectionText;
277
+ status.className = 'connection-status';
278
+
279
+ switch (State.wsState) {
280
+ case 'connected':
281
+ status.classList.add('connected');
282
+ text.textContent = 'Connected';
283
+ break;
284
+ case 'connecting':
285
+ case 'reconnecting':
286
+ status.classList.add('reconnecting');
287
+ text.textContent = State.wsState === 'reconnecting' ? 'Reconnecting...' : 'Connecting...';
288
+ break;
289
+ default:
290
+ status.classList.add('disconnected');
291
+ text.textContent = 'Disconnected - trying to reconnect...';
292
+ break;
293
  }
 
294
  }
295
 
296
+ // ====================================
297
+ // WebSocket Message Handler
298
+ // ====================================
299
  function handleWSMessage(msg) {
300
  switch (msg.type) {
301
+ case 'connection_established':
302
+ State.user = {
303
+ id: msg.user_id,
304
+ username: msg.username,
305
+ display_name: msg.display_name,
306
+ avatar_path: msg.avatar_path
307
+ };
308
+ msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
309
+ updateOnlineBadge();
310
+ updateHeaderInfo();
311
  break;
312
+
313
  case 'new_message':
314
+ handleNewMessage(msg.message);
315
+ break;
316
+
317
+ case 'messages_loaded':
318
+ handleMessagesLoaded(msg.messages, msg.has_more);
319
+ State.hasMore = msg.has_more;
320
+ if (msg.next_cursor) State.cursor = msg.next_cursor;
321
+ DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
322
+ break;
323
+
324
+ case 'user_status':
325
+ if (msg.status === 'online') {
326
+ State.onlineUsers.set(msg.user_id, { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path });
327
+ } else {
328
+ State.onlineUsers.delete(msg.user_id);
329
  }
330
+ updateOnlineBadge();
331
  break;
332
+
333
+ case 'typing_indicator':
334
+ if (msg.is_typing) {
335
+ State.typingUsers.set(msg.user_id, msg);
336
+ } else {
337
+ State.typingUsers.delete(msg.user_id);
 
 
 
 
338
  }
339
+ updateTypingIndicator();
340
  break;
341
+
342
+ case 'message_edited':
343
+ handleMessageEdit(msg.message_id, msg.content, msg.editor_id);
344
  break;
345
+
346
+ case 'message_deleted':
347
+ handleMessageDelete(msg.message_id);
 
 
 
348
  break;
349
+
350
+ case 'message_read':
351
+ handleMessageRead(msg.message_id, msg.reader_username);
 
 
352
  break;
353
+
354
+ case 'online_users':
355
+ State.onlineUsers.clear();
356
+ msg.users.forEach(u => State.onlineUsers.set(u.id, u));
357
+ updateOnlineBadge();
358
+ break;
359
+
360
+ case 'error':
361
+ showToast(msg.message, 'error');
362
  break;
363
  }
364
  }
365
 
366
+ // ====================================
367
+ // Message Handling
368
+ // ====================================
369
+ function handleNewMessage(message) {
370
+ if (State.messages.has(message.id)) return;
371
+ State.messages.set(message.id, message);
372
+ State.messageOrder.push(message.id);
373
+ renderMessage(message);
374
+ scrollToBottom();
375
+
376
+ // Mark as read if visible
377
+ if (document.visibilityState === 'visible') {
378
+ sendMarkRead(message.id);
379
  }
380
 
381
+ // Browser notification if tab not focused
382
+ if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
383
+ const sender = message.sender_display_name || message.sender_username;
384
+ const body = message.content || (message.file_path ? 'Sent a file' : 'New message');
385
+ new Notification(`📨 ${sender}`, { body, icon: '/static/icon.png' });
386
+ }
387
+ }
388
+
389
+ function handleMessagesLoaded(msgs, hasMore) {
390
+ msgs.forEach(msg => {
391
+ if (!State.messages.has(msg.id)) {
392
+ State.messages.set(msg.id, msg);
393
+ State.messageOrder.unshift(msg.id);
394
+ }
395
+ });
396
+ renderAllMessages();
397
+ // Keep scroll position
398
+ }
399
+
400
+ function handleMessageEdit(messageId, newContent, editorId) {
401
+ const msg = State.messages.get(messageId);
402
+ if (msg) {
403
+ msg.content = newContent;
404
+ msg.is_edited = true;
405
+ updateMessageBubble(messageId);
406
+ }
407
  }
408
 
409
+ function handleMessageDelete(messageId) {
410
+ const msg = State.messages.get(messageId);
411
+ if (msg) {
412
+ msg.is_deleted = true;
413
+ updateMessageBubble(messageId);
414
+ }
415
+ }
416
+
417
+ function handleMessageRead(messageId, readerUsername) {
418
+ const msg = State.messages.get(messageId);
419
+ if (msg && msg.sender_id === State.user.id) {
420
+ msg.status = 'read';
421
+ updateMessageBubble(messageId);
422
+ }
423
+ }
424
+
425
+ // ====================================
426
+ // Message Rendering
427
+ // ====================================
428
+ function renderMessage(msg) {
429
+ if (State.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
430
+
431
+ const wrapper = document.createElement('div');
432
+ wrapper.className = `message-wrapper ${msg.sender_id === State.user.id ? 'self' : 'other'}`;
433
+ wrapper.dataset.id = msg.id;
434
+ wrapper.innerHTML = buildMessageHTML(msg);
435
+ State.messagesList.appendChild(wrapper);
436
+ }
437
+
438
+ function buildMessageHTML(msg) {
439
+ if (msg.is_deleted) {
440
+ return `<div class="message-bubble deleted">This message was deleted</div>`;
441
+ }
442
 
443
+ const isSelf = msg.sender_id === State.user?.id;
444
  let html = '';
445
+
446
+ // Reply indicator
447
+ if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
448
+ const reply = State.messages.get(msg.reply_to_id);
449
+ html += `<div class="reply-indicator">
450
+ <i class="fas fa-reply"></i>
451
+ <span>${reply.sender_username}: ${truncate(reply.content, 50)}</span>
452
+ </div>`;
453
  }
454
 
455
+ // Bubble content
456
+ html += `<div class="message-bubble">`;
 
 
457
  html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
458
 
459
+ // Attachment
460
+ if (msg.file_path) {
461
+ html += buildAttachmentHTML(msg);
 
 
 
 
 
 
 
 
 
462
  }
463
 
464
+ // Meta
465
+ html += `<div class="message-meta">
466
+ <span class="message-time">${formatTime(msg.timestamp_ms)}</span>`;
467
+ if (msg.is_edited) html += `<span class="edited-label">edited</span>`;
468
+ if (isSelf) {
469
+ const checkIcon = msg.status === 'read' ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>';
470
+ html += `<span class="message-status ${msg.status === 'read' ? 'read' : ''}">${checkIcon}</span>`;
471
+ }
472
+ html += `</div>`;
473
+
474
+ // Actions
475
+ html += `<div class="message-actions">
476
+ <button onclick="InfinityChat.replyTo(${msg.id})" title="Reply"><i class="fas fa-reply"></i></button>`;
477
+ if (isSelf) {
478
+ html += `<button onclick="InfinityChat.editMessage(${msg.id})" title="Edit"><i class="fas fa-pen"></i></button>
479
+ <button onclick="InfinityChat.deleteMessage(${msg.id})" title="Delete"><i class="fas fa-trash"></i></button>`;
480
+ }
481
+ html += `</div>`;
482
+ html += `</div>`;
483
+
484
  return html;
485
  }
486
 
487
+ function buildAttachmentHTML(msg) {
488
+ const url = `${API_BASE}/api/download/${encodeURIComponent(msg.file_path)}?token=${encodeURIComponent(State.token)}`;
489
+ if (msg.file_type?.startsWith('image/')) {
490
+ return `<div class="attachment">
491
+ <img src="${url}" alt="${msg.file_name}" loading="lazy" onclick="window.open('${url}')">
492
+ </div>`;
493
+ }
494
+ if (msg.file_type?.startsWith('video/')) {
495
+ return `<div class="attachment">
496
+ <video controls preload="metadata"><source src="${url}" type="${msg.file_type}"></video>
497
+ </div>`;
498
+ }
499
+ if (msg.file_type?.startsWith('audio/')) {
500
  return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
501
  }
502
+ return `<div class="attachment-file" onclick="window.open('${url}')">
503
+ <i class="fas fa-file"></i>
504
+ <div class="file-info">
505
+ <div class="file-name">${msg.file_name || 'File'}</div>
506
+ <div class="file-size">${formatFileSize(msg.file_size)}</div>
507
+ </div>
508
+ </div>`;
509
  }
510
 
511
+ function updateMessageBubble(messageId) {
512
+ const existing = State.messagesList.querySelector(`[data-id="${messageId}"]`);
513
+ if (!existing) return;
514
+ const msg = State.messages.get(messageId);
515
  if (!msg) return;
516
+ existing.innerHTML = buildMessageHTML(msg);
 
 
 
 
 
 
 
 
 
 
517
  }
518
 
519
+ function renderAllMessages() {
520
+ State.messagesList.innerHTML = '';
521
+ if (State.messageOrder.length === 0) {
522
+ State.emptyState.style.display = 'flex';
523
+ } else {
524
+ State.emptyState.style.display = 'none';
525
+ State.messageOrder.forEach(id => {
526
+ const msg = State.messages.get(id);
527
+ if (msg) renderMessage(msg);
528
+ });
529
+ }
530
  }
531
 
532
+ // ====================================
533
+ // Sending Messages
534
+ // ====================================
535
+ DOM.composeForm.addEventListener('submit', (e) => {
536
+ e.preventDefault();
537
+ sendMessage();
 
 
 
 
 
 
 
538
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
+ function sendMessage() {
541
+ const content = DOM.messageInput.value.trim();
542
+ if (!content && !State.pendingFile) return;
543
+ if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return;
544
+
545
+ const payload = {
546
+ type: 'send_message',
547
+ content: content,
548
+ reply_to_id: State.replyTo ? State.replyTo.id : null,
549
+ client_id: generateClientId()
 
 
 
 
 
 
550
  };
551
+
552
+ if (State.pendingFile) {
553
+ payload.file_path = State.pendingFile.file_path;
554
+ payload.file_type = State.pendingFile.file_type;
555
+ payload.file_name = State.pendingFile.file_name;
556
+ payload.file_size = State.pendingFile.file_size;
557
  }
558
+
559
+ State.ws.send(JSON.stringify(payload));
560
+ DOM.messageInput.value = '';
561
  clearReply();
562
+ clearPendingFile();
563
+ autoResizeTextarea();
564
+ sendTyping(false);
565
+ }
566
+
567
+ DOM.sendBtn.disabled = true;
568
+ DOM.messageInput.addEventListener('input', () => {
569
+ const hasContent = DOM.messageInput.value.trim().length > 0 || State.pendingFile !== null;
570
+ DOM.sendBtn.disabled = !hasContent;
571
+ if (hasContent) sendTyping(true);
572
+ autoResizeTextarea();
573
  });
574
 
575
+ // ====================================
576
+ // Typing Indicator
577
+ // ====================================
578
+ let typingTimer = null;
579
+ function sendTyping(isTyping) {
580
+ if (isTyping) {
581
+ clearTimeout(typingTimer);
582
+ if (State.ws?.readyState === WebSocket.OPEN) {
583
+ State.ws.send(JSON.stringify({ type: 'typing', is_typing: true }));
584
+ }
585
+ typingTimer = setTimeout(() => sendTyping(false), 2000);
586
+ } else {
587
+ clearTimeout(typingTimer);
588
+ if (State.ws?.readyState === WebSocket.OPEN) {
589
+ State.ws.send(JSON.stringify({ type: 'typing', is_typing: false }));
590
+ }
591
+ }
592
+ }
593
+
594
+ function updateTypingIndicator() {
595
+ const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user.id);
596
+ DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
597
+ if (users.length > 0) {
598
+ DOM.typingText.textContent = users.map(u => u.display_name).join(', ') + ' typing...';
599
+ }
600
+ }
601
+
602
+ // ====================================
603
+ // Reply
604
+ // ====================================
605
+ function replyTo(messageId) {
606
+ const msg = State.messages.get(messageId);
607
+ if (!msg) return;
608
+ State.replyTo = msg;
609
+ DOM.replyUser.textContent = msg.sender_username;
610
+ DOM.replyText.textContent = truncate(msg.content, 50);
611
+ DOM.replyPreview.classList.remove('hidden');
612
+ DOM.messageInput.focus();
613
+ }
614
+
615
  function clearReply() {
616
+ State.replyTo = null;
617
+ DOM.replyPreview.classList.add('hidden');
618
+ }
619
+
620
+ document.addEventListener('click', (e) => {
621
+ if (e.target.id === 'cancel-reply') clearReply();
 
 
 
 
 
 
622
  });
623
 
624
+ // ====================================
625
+ // Edit & Delete
626
+ // ====================================
627
+ function editMessage(messageId) {
628
+ const msg = State.messages.get(messageId);
629
+ if (!msg) return;
630
+ const newContent = prompt('Edit message:', msg.content);
631
+ if (newContent !== null && newContent.trim() !== '') {
632
+ State.ws.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent }));
633
+ }
634
+ }
635
+
636
+ function deleteMessage(messageId) {
637
+ if (confirm('Delete this message permanently?')) {
638
+ State.ws.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
639
+ }
640
+ }
641
+
642
+ // ====================================
643
+ // File Upload (Chunked)
644
+ // ====================================
645
+ DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
646
+ DOM.fileInput.addEventListener('change', handleFileSelect);
647
+
648
+ async function handleFileSelect(e) {
649
  const file = e.target.files[0];
650
  if (!file) return;
651
+ await uploadFileChunked(file);
652
+ DOM.fileInput.value = '';
653
+ }
654
+
655
+ async function uploadFileChunked(file) {
656
+ const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
657
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
658
+ const uploadId = generateClientId();
659
+
660
+ DOM.uploadProgress.classList.remove('hidden');
661
+ updateProgress(0, file.name);
662
+
663
+ for (let i = 0; i < totalChunks; i++) {
664
+ const start = i * CHUNK_SIZE;
665
+ const end = Math.min(start + CHUNK_SIZE, file.size);
666
  const blob = file.slice(start, end);
667
+
668
+ const formData = new FormData();
669
+ formData.append('file', blob);
670
+
671
+ const url = `${API_BASE}/api/upload/chunk?` +
672
+ `chunk_index=${i}&total_chunks=${totalChunks}` +
673
+ `&file_name=${encodeURIComponent(file.name)}` +
674
+ `&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}` +
675
+ `&file_size=${file.size}&upload_id=${uploadId}`;
676
+
677
+ const res = await fetch(url, { method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token } });
678
+ if (!res.ok) throw new Error('Upload failed');
679
+ const data = await res.json();
680
+
681
+ if (data.status === 'complete') {
682
+ State.pendingFile = {
683
+ file_path: data.file_path,
684
+ file_type: data.file_type,
685
+ file_name: data.file_name,
686
+ file_size: data.file_size
687
+ };
688
+ showUploadPreview(data.file_name);
689
+ DOM.uploadProgress.classList.add('hidden');
690
+ return;
 
 
 
 
 
 
 
 
691
  }
692
+ updateProgress(((i + 1) / totalChunks) * 100, file.name);
693
  }
694
+ DOM.uploadProgress.classList.add('hidden');
695
+ }
696
+
697
+ function updateProgress(percent, filename) {
698
+ DOM.progressFill.style.width = `${percent}%`;
699
+ DOM.progressText.textContent = `${Math.round(percent)}%`;
700
+ DOM.uploadFilename.textContent = filename;
701
+ }
702
+
703
+ function showUploadPreview(filename) {
704
+ DOM.uploadPreview.classList.remove('hidden');
705
+ DOM.uploadFilename.textContent = filename;
706
+ DOM.sendBtn.disabled = false;
707
+ }
708
+
709
+ function clearPendingFile() {
710
+ State.pendingFile = null;
711
+ DOM.uploadPreview.classList.add('hidden');
712
+ }
713
+
714
+ document.getElementById('cancel-upload').addEventListener('click', clearPendingFile);
715
+
716
+ // ====================================
717
+ // Messages Loading & Pagination
718
+ // ====================================
719
+ function loadMessages() {
720
+ if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return;
721
+ State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
722
+ }
723
 
724
+ DOM.loadOlderBtn.addEventListener('click', () => {
725
+ if (State.cursor && State.hasMore) {
726
+ State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
 
 
727
  }
728
  });
729
 
730
+ // Scroll detection for load more and auto-scroll behavior
731
+ DOM.messagesScroll.addEventListener('scroll', () => {
732
+ const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
733
+ State.isAtBottom = scrollHeight - scrollTop - clientHeight < 60;
734
+ DOM.scrollBottomBtn.classList.toggle('hidden', State.isAtBottom);
735
+ });
736
+
737
+ DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
738
+
739
+ function scrollToBottom() {
740
+ DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
741
+ }
742
+
743
+ // ====================================
744
+ // Online Users Modal
745
+ // ====================================
746
+ function requestOnlineUsers() {
747
+ if (State.ws?.readyState === WebSocket.OPEN) {
748
+ State.ws.send(JSON.stringify({ type: 'get_online_users' }));
749
  }
750
  }
751
 
752
+ function updateOnlineBadge() {
753
+ DOM.onlineBadge.textContent = State.onlineUsers.size;
754
+ }
755
+
756
+ // ====================================
757
+ // Settings Modal
758
+ // ====================================
759
+ document.getElementById('settings-btn').addEventListener('click', openSettings);
760
+ document.getElementById('save-settings').addEventListener('click', saveSettings);
761
+
762
+ function openSettings() {
763
+ DOM.settingsDisplayName.value = State.user?.display_name || '';
764
+ DOM.settingsUsername.value = State.user?.username || '';
765
+ if (State.user?.avatar_path) {
766
+ DOM.settingsAvatarImg.src = `${API_BASE}/api/download/${encodeURIComponent(State.user.avatar_path)}?token=${encodeURIComponent(State.token)}`;
767
+ DOM.settingsAvatarImg.classList.remove('hidden');
768
+ DOM.settingsAvatarPreview.classList.add('hidden');
769
  } else {
770
+ DOM.settingsAvatarImg.classList.add('hidden');
771
+ DOM.settingsAvatarPreview.classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  }
773
 
774
+ DOM.themeOptions.forEach(opt => opt.classList.toggle('active', opt.dataset.theme === State.theme));
775
+ DOM.customThemeSettings.classList.toggle('hidden', State.theme !== 'custom');
776
+ DOM.settingsModal.classList.remove('hidden');
777
+ }
778
+
779
+ async function saveSettings() {
780
+ const newName = DOM.settingsDisplayName.value.trim();
781
+ if (newName && newName !== State.user?.display_name) {
782
+ const res = await fetch(`${API_BASE}/api/profile?display_name=${encodeURIComponent(newName)}`, {
783
+ method: 'PATCH', headers: { 'X-Auth-Token': State.token }
784
  });
 
785
  if (res.ok) {
786
+ State.user.display_name = newName;
787
+ DOM.headerDisplayName.textContent = newName;
788
+ showToast('Profile updated', 'success');
 
789
  }
790
  }
791
+ DOM.settingsModal.classList.add('hidden');
792
+ }
 
 
 
 
 
 
 
 
 
 
 
 
793
 
794
+ // ====================================
795
+ // Theme Switching
796
+ // ====================================
797
+ DOM.themeOptions.forEach(opt => {
798
+ opt.addEventListener('click', () => {
799
+ DOM.themeOptions.forEach(o => o.classList.remove('active'));
800
+ opt.classList.add('active');
801
+ applyTheme(opt.dataset.theme);
802
+ DOM.customThemeSettings.classList.toggle('hidden', opt.dataset.theme !== 'custom');
803
+ });
 
 
 
 
 
 
 
 
 
 
 
804
  });
805
 
806
+ // ====================================
807
+ // Utility Functions
808
+ // ====================================
809
+ function generateClientId() {
810
+ return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
811
  }
812
 
813
+ function truncate(str, n) {
814
+ if (!str) return '';
815
+ return str.length > n ? str.substr(0, n - 1) + '…' : str;
 
816
  }
817
 
818
  function escapeHtml(text) {
 
822
  }
823
 
824
  function formatTime(ms) {
825
+ const date = new Date(ms);
826
  const now = new Date();
827
+ const diff = now - date;
828
+ if (diff < 60000) return 'Just now';
829
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
830
+ if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
831
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
832
+ }
833
+
834
+ function formatFileSize(bytes) {
835
+ if (!bytes) return '0 B';
836
+ const k = 1024;
837
+ const sizes = ['B', 'KB', 'MB', 'GB'];
838
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
839
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
840
+ }
841
+
842
+ function autoResizeTextarea() {
843
+ DOM.messageInput.style.height = 'auto';
844
+ DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
845
+ }
846
+
847
+ // Expose public methods
848
+ window.InfinityChat = {
849
+ replyTo,
850
+ editMessage,
851
+ deleteMessage
852
+ };
853
+
854
+ // ====================================
855
+ // Initialization
856
+ // ====================================
857
+ async function init() {
858
+ if (State.token) {
859
+ const res = await fetch(`${API_BASE}/api/auth/verify`, { headers: { 'X-Auth-Token': State.token } });
860
+ if (res.ok) {
861
+ const data = await res.json();
862
+ State.user = data.user;
863
+ showChat();
864
+ connectWebSocket();
865
+ return;
866
+ }
867
  }
868
+ showAuth();
869
  }
870
 
871
+ function showAuth() {
872
+ DOM.authScreen.classList.add('active');
873
+ DOM.chatScreen.classList.remove('active');
874
+ }
875
+
876
+ function showChat() {
877
+ DOM.authScreen.classList.remove('active');
878
+ DOM.chatScreen.classList.add('active');
879
+ updateHeaderInfo();
880
+ }
881
+
882
+ function updateHeaderInfo() {
883
+ if (State.user) {
884
+ DOM.headerDisplayName.textContent = State.user.display_name;
885
  }
886
+ }
887
+
888
+ // Close modals on backdrop click
889
+ document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
890
+ backdrop.addEventListener('click', () => {
891
+ const modal = backdrop.closest('.modal');
892
+ modal.classList.add('hidden');
893
+ });
894
+ });
895
+
896
+ // Request notification permission
897
+ if (window.Notification && Notification.permission === 'default') {
898
+ Notification.requestPermission();
899
+ }
900
+
901
+ // Handle online/offline
902
+ DOM.onlineBtn.addEventListener('click', () => {
903
+ requestOnlineUsers();
904
+ DOM.onlineModal.classList.remove('hidden');
905
+ });
906
+
907
+ init();
908
  })();